mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(db): add Lark integration migration (MUL-2671) Introduces seven tables for the 飞书 Bot integration MVP — per-agent PersonalAgent installations, user/chat bindings, inbound dedup + non-content drop audit, outbound card mapping, and short-lived single-use member binding tokens. Schema notes: - chat_session schema unchanged; Lark routes through a separate binding table rather than adding a metadata JSONB column. - Outbound card mapping is task/message scoped so multiple runs on the same session can't stomp each other's cards. - lark_inbound_audit stores routing / identity / drop_reason ONLY, never message body — the audit channel for unbound users and group messages that don't address the Bot. - app_secret stores ciphertext (encryption helper lands in a follow-up commit on this branch); DB never sees plaintext. Co-authored-by: multica-agent <github@multica.ai> * feat(util): add secretbox AES-256-GCM helper for at-rest secrets First consumer is lark_installation.app_secret (MUL-2671 §4.4), but the helper is intentionally generic — future per-tenant secrets that must not appear in a DB dump can reuse it. Construction: AES-256-GCM with a per-message random nonce, providing authenticated encryption. Tampered ciphertext fails Open instead of silently decrypting to garbage. Master key loaded from a base64 env var via LoadKey; key rotation is not in scope yet. Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): extract IssueService.Create as single create entry (MUL-2671) Establishes the service-layer boundary mandated by Elon's 二审 of MUL-2671 §4.8: issue creation no longer lives inside the HTTP handler. Both the HTTP POST /issues handler and the future Lark /issue command call into service.IssueService.Create, so duplicate guard, issue numbering, attachment linking, broadcast, analytics, and agent/squad enqueue stay aligned. Handler responsibilities shrink to parsing the HTTP request, doing actor resolution / validation (transport-specific), and converting service results into the IssueResponse + 201. The transaction-wrapped core, attachment link, event publish, analytics capture, and agent/squad enqueue all move into service.IssueService.Create. A BroadcastPayload callback on the service keeps the WS broadcast shape (the full IssueResponse) without forcing the service to depend on handler-layer response types. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations): add Lark package skeleton (MUL-2671) Establishes the architectural boundaries Elon's 二审 mandated as first-PR blockers without dragging in OAuth, WebSocket, or card-patching code (those land in follow-up PRs): - ChatSessionService interface — channel-aware chat-session entry point for Lark, deliberately separate from the HTTP SendChatMessage handler. The HTTP handler's single-creator guard (creator_id == request user_id) is correct for the browser client but rejects group chat_sessions by construction; Lark needs its own service. - AuditLogger interface — the only path for recording dropped events. Its signature deliberately omits message body, enforcing the drop-audit policy (MUL-2671 §4.7) at the type level: unbound users and non-addressed group messages can't accidentally end up in chat_session. - Typed IDs (OpenID, ChatID) prevent UUIDs from being conflated with Lark-side identifiers at compile time. - DropReason constants align dashboard/audit queries across callers. Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): move parent/project workspace check into IssueService (MUL-2671) Parent existence and project workspace membership now live inside IssueService.Create, inside the same transaction as the duplicate guard and counter increment. The HTTP handler stops re-implementing the lookup; every future create entry (Lark /issue, MCP, API keys) inherits the same boundary without copy-pasting the SQL. Adds two error sentinels (ErrParentIssueNotFound, ErrProjectNotFound) so transports can translate to their own error shapes. Handler-level cross-workspace tests guard the boundary against future regressions. Co-authored-by: multica-agent <github@multica.ai> * fix(db): harden Lark migration safety底座 — TTL cap + workspace FK (MUL-2671) Two storage-layer hardenings that move the must-fix line off "the app layer enforces it" and onto the schema itself, so future write paths or hand-inserted rows cannot regress the invariants. 1) lark_binding_token TTL cap. The DB CHECK was 1 hour as defense-in-depth while the app constant was 15 minutes; the CHECK now matches the product cap (15 minutes). Application constant docstring updated to reflect that storage enforces the same bound. 2) lark_user_binding workspace membership. The table previously only FK'd to workspace / user / installation independently, so a binding could exist for a user no longer in the workspace, or claim a workspace different from its installation's. Two composite FKs close the gap structurally: * (installation_id, workspace_id) → lark_installation(id, workspace_id) — guarantees a binding's workspace_id always matches its installation's workspace_id. A new UNIQUE (id, workspace_id) on lark_installation is added as the FK target. * (workspace_id, multica_user_id) → member(workspace_id, user_id) with ON DELETE CASCADE — when a user is removed from the workspace, the binding cascades away in the same transaction. There is no longer a path where lark_user_binding outlives workspace membership. These two FKs are the schema-level proof for §4.3's "unbound or non-workspace members cannot leak content into chat_session" invariant. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): inbound services + /issue dispatcher (MUL-2671) Lands the inbound service layer for the Lark Bot MVP, sitting on top of the migration + service-boundary scaffold from the previous commits. What ships: - sqlc queries for all seven lark_* tables (idempotent dedup insert, CAS WS-lease, single-use binding-token consume, etc.) plus GetMostRecentUserChatMessage for the /issue fallback. - AuditLogger backed by lark_inbound_audit; signature deliberately body-free so callers cannot leak content into the drop log. - ChatSessionService: find-or-create chat_session via the binding table (winner-takes-all on the UNIQUE race), append-with-dedup, /issue parser, "previous user message" fallback for bare `/issue` invocation. - Dispatcher orchestrates the inbound pipeline in one place: installation routing → group-mention filter → identity check → ensure session → append+dedup → /issue → enqueue chat task. Group sessions use the installer as creator (stable workspace identity); p2p uses the sender. Agent-offline path falls through with OutcomeAgentOffline so the WS adapter can reply with the offline notice from §4.6. - BindingTokenService: random URL-safe token, SHA-256 stored hash, 15-min TTL pinned at the application AND the DB CHECK; Redeem returns the same opaque error for all rejection cases (no timing oracle on replay). - Unit tests for the parser (13 cases), dispatcher (8 cases via fake Queries/Chat/Audit/IssueCreator/Enqueuer), and binding-token hash/entropy. Real-DB integration tests for OAuth + token redeem land alongside the HTTP handlers in the next commit. Out of scope for this commit (next ones on the same feature branch): OAuth callback, HTTP routes, WebSocket hub, outbound card patcher, frontend. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): installation HTTP surface + secretbox-gated wiring (MUL-2671) Lands the HTTP boundary on top of the inbound services from the previous commit. What ships: - InstallationService.Upsert: the only path that writes lark_installation. Encrypts app_secret with the secretbox passed in at construction time; refuses to fall back to plaintext storage (returns an error from the constructor if no Box is supplied), so a misconfigured dev environment cannot accidentally land a row with cleartext credentials. Revoke flips status without DELETE so audit trail survives. - HTTP handlers under /api/workspaces/{id}/lark/: * GET /installations — member-visible (Integrations tab renders for non-admins). Soft 200 with empty list + configured:false when MULTICA_LARK_SECRET_KEY is unset, so the tab does not error on self-host that has not opted in. * POST /installations — admin-only; 503 when not configured. Re-validates agent_id ∈ workspace before accepting credentials so a cross-workspace agent UUID is rejected. * DELETE /installations/{id} — admin-only; workspace-scoped lookup so one workspace cannot revoke another's installation by UUID guess. - POST /api/lark/binding/redeem (user-scoped, no workspace context): the only path that mints a lark_user_binding row from user action. Redeemer identity comes from the session, not the token, so a stolen link cannot bind an open_id to an attacker's Multica user. The composite FK on lark_user_binding cascades the binding away if the user is not (or no longer) a workspace member, so a non-member who steals the link gets 403 at the DB layer. - Two new event-bus types in protocol.events: EventLarkInstallationCreated, EventLarkInstallationRevoked. - Router wiring: MULTICA_LARK_SECRET_KEY drives a conditional initialization of h.LarkInstallations + h.LarkBindingTokens. When unset, the integration disables itself with an INFO log and the rest of the server boots normally. - Handler tests cover all four not-configured short-circuits. Happy-path integration tests (real DB, full create→list→revoke cycle and token mint→redeem) ship alongside the WS hub PR. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): close binding-token rebind & typed task errors (MUL-2671) Two must-fixes from PR review on HEAD87ad15e1: 1. Binding-token redeem could be used to grab an already-bound Lark open_id. Two changes harden the path: - lark.sql `CreateLarkUserBinding` now gates ON CONFLICT DO UPDATE on `multica_user_id = EXCLUDED.multica_user_id`, so a cross-user rebind via a second valid token returns zero rows instead of silently switching ownership. - `BindingTokenService.RedeemAndBind` consumes the token and writes the binding row inside one transaction. A failed bind no longer burns the token; a successful bind never leaves a consumed-but- unused token. Distinct typed errors: ErrBindingTokenInvalid (410), ErrBindingAlreadyAssigned (409), ErrBindingNotWorkspaceMember (403). The handler maps each to its own status code. 2. Dispatcher collapsed every `EnqueueChatTask` error to `OutcomeAgentOffline`, hiding infra failure and misusing the "offline" label for cases (e.g. archived agent) where it doesn't fit. Now: - `service.EnqueueChatTask` returns `ErrChatTaskAgentNoRuntime` and `ErrChatTaskAgentArchived` as sentinel errors; DB / load / insert failures stay wrapped as ordinary errors. - Dispatcher uses `errors.Is` to map only the productizable cases (`OutcomeAgentOffline`, new `OutcomeAgentArchived`); any other error is returned to the WS adapter so it can retry or page instead of disguising the outage as an offline card. A daemon that's merely disconnected is still NOT an error — as long as `agent.runtime_id` is set the chat task enqueues and waits for the daemon to claim it on next online (returns `OutcomeIngested`). Co-authored-by: multica-agent <github@multica.ai> * ci: re-trigger workflow on lark MVP must-fix HEAD Co-authored-by: multica-agent <github@multica.ai> * ci: re-trigger workflow on lark MVP must-fix HEAD (retry) Co-authored-by: multica-agent <github@multica.ai> * test(integrations/lark): guard binding-token sentinel contract (MUL-2671) Two unit tests that document and protect the must-fix invariants without requiring a DB: 1. TestRedeemAndBindRequiresTxStarter — if a future refactor wires up BindingTokenService without a TxStarter, RedeemAndBind must fail fast with a clear error rather than nil-panic on Begin. The atomicity contract (consume + bind commit together) depends on that transaction existing. 2. TestBindingErrorSentinelsAreDistinct — the HTTP handler maps ErrBindingTokenInvalid → 410, ErrBindingAlreadyAssigned → 409, ErrBindingNotWorkspaceMember → 403. Accidentally aliasing them (e.g. var ErrBindingAlreadyAssigned = ErrBindingTokenInvalid) would silently regress the response codes without any other test catching it. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): WS hub orchestrator + outbound card patcher (MUL-2671) The hub owns one supervisor goroutine per active installation. Each supervisor acquires the WS lease via the existing CAS query, runs an EventConnector (interface — real Lark wire protocol lands in a follow-up behind it), renews the lease on a tighter cadence than the TTL, and backs off (with jitter) on connector failure. Lease loss tears the connector down cleanly; revocation is reaped on the next sweep. Per- process node id satisfies §4.4 multi-replica safety: at most one Hub globally holds the lease for any installation. The patcher subscribes to task / chat-done events on the existing events.Bus and keeps the per-task Lark interactive card in sync (thinking → streaming → final | error). Card binding is per-task as required by §4.5; throttled patches via an in-memory last-patched map; final / error transitions bypass the throttle so the user always sees the terminal state. The Renderer is plug-replaceable so the product card template can evolve without touching transport. The APIClient interface centralizes the Lark Open Platform surface this package needs (send card, patch card, send binding prompt, exchange OAuth code). The default stubAPIClient returns ErrAPIClientNotConfigured for every transport call so a misconfigured deployment fails loudly instead of dropping cards silently. Real implementation lands in a follow-up; OAuth callback + frontend entries land in the next commits on this branch. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): OAuth install start / callback (MUL-2671) OAuthService builds a signed-state Lark authorization URL the frontend can render as a QR (or open directly), then on callback verifies the HMAC-protected state, exchanges the OAuth code for installation credentials via APIClient.ExchangeOAuthCode, and persists the row via InstallationService.Upsert (which keeps app_secret encryption inside a single chokepoint). State token format: workspaceID.agentID.initiatorID.expiresUnix.nonce.sig — HMAC-SHA256 over the first five fields with a deployment-level secret. TTL defaults to 10 minutes (covered by tests). Three failure modes (invalid state / expired state / missing code) map to typed errors so the HTTP handler can emit a single lark_error= query param the frontend uses to pick copy. Both endpoints degrade cleanly: the at-rest key gate (already in place) returns 503 from /install/start when the InstallationService is nil, and the OAuth gate (MULTICA_LARK_OAUTH_APP_ID / _SECRET / _REDIRECT_URI / _STATE_SECRET) returns configured:false from /install/start so the frontend can render "configure manually instead" without an error banner. /install/callback always finishes with a redirect to /settings?tab=lark carrying either lark_installed=1 or lark_error=<code>. Tests cover signed-URL shape, missing-config rejection, tampered state, expired state, propagated exchange error, and the no-config redirect path on the HTTP handler. Co-authored-by: multica-agent <github@multica.ai> * feat(views/lark): settings tab + agent bind button + /lark/bind redemption page (MUL-2671) Adds the user-facing Lark surface across the shared packages: - packages/core/types/lark.ts — wire shapes that mirror server/internal/ handler/lark.go. Optional fields default to undefined so older desktop builds keep parsing if the server adds new keys (CLAUDE.md → API Response Compatibility). - packages/core/lark/{queries,index}.ts — Tanstack Query options keyed by workspace id; realtime sync invalidates `installations(wsId)` on `lark_installation:*` events. - packages/core/api/client.ts — listLarkInstallations, getLarkInstallURL, deleteLarkInstallation, redeemLarkBindingToken. - packages/views/settings/components/lark-tab.tsx — Settings → Lark panel. Listing is member-visible (matches backend); disconnect is admin-only. Empty state points users at the per-Agent bind entry, matching the (workspace_id, agent_id) UNIQUE: there is no "pick an agent" UI here because the bind URL is per-agent. - LarkAgentBindButton (same file) is the per-Agent CTA the Agent detail page imports. Opens the OAuth URL in a new tab; the callback bounces back to /settings?tab=lark with a query param the panel reads for inline confirmation copy. - packages/views/lark/bind-page.tsx — the Bot's "you need to bind" destination. Requires session before redeeming, distinguishes the 410/409/403 backend responses into distinct copy. - apps/web/app/lark/bind/page.tsx — Next.js route wrapping the shared bind page in a Suspense boundary (Next 15 useSearchParams rule). i18n: all user-facing strings land in en/zh-Hans, settings tab nav includes a Sparkles-iconed Lark entry, bind-page copy lives under common.lark_bind so it works pre-workspace-context too. typecheck + lint clean. Co-authored-by: multica-agent <github@multica.ai> * chore(integrations/lark): wire outbound Patcher into server bootstrap (MUL-2671) Constructs the Patcher next to the existing Installation/BindingToken wiring in router.go and Register()s it on the event bus. With the stub APIClient any actual transport call surfaces ErrAPIClientNotConfigured; once the real Lark client lands, swap NewStubAPIClient for the real implementation here without touching the Patcher's subscription logic. doc.go updated to reflect everything the package now contains (Hub, Patcher, OAuthService, APIClient interface). The Hub itself is NOT booted here yet — it needs an EventConnector implementation for the Lark long-connection wire protocol, which lands in a follow-up; the orchestrator code and its unit tests are in place so that follow-up can focus on the WS protocol rather than lifecycle plumbing. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): address Elon 二审 5 must-fix items (MUL-2671) - Hub: renewer cancels run ctx on lease loss so the connector exits even if its wire I/O is blocked, keeping the §4.4 ownership invariant intact under lease theft. - Hub: EventEmitter returns (DispatchResult, error) so the real connector can post the matching Lark-side card (needs_binding, agent_offline, agent_archived) and react to infra failures instead of silently logging at the seam. - Dispatcher: top-level message_id dedup runs before group filter and identity check, so a reconnect storm cannot re-fire binding prompts or re-spam not_addressed_in_group audit rows; the in- AppendUserMessage dedup is removed since the table-level UNIQUE is the ultimate backstop. - OAuth: HandleCallback auto-binds the installer via the new InstallerBinder seam (BindingTokenService implements it), so the §2.1 "scan to bind, you're done" promise holds end-to-end. validateExchangeResult now requires installer open_id; new error reason codes wired through the callback redirect. - Frontend / handler: install_supported listing field + StartLark- Install short-circuit on stub APIClient hide install entry points (Settings tab + per-agent button) while no real Lark HTTP client is wired, so users do not land in an OAuth flow that fails at exchange. Includes tests for each fix (lease-loss cancel, emit error propagation, dedup ordering, OAuth installer-bind contract, stub- client install gate) and i18n strings for the new preview state. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): two-phase dedup so infra failures do not swallow messages (MUL-2671) The pre-fix top-level dedup wrote the lark_inbound_message_dedup row before EnsureChatSession / AppendUserMessage. An infra error in either step left the row in place and a WS-adapter retry was mis-classified as a duplicate, so the user's Lark message was permanently lost without ever landing in chat_session. Make dedup two-phase: - ClaimLarkInboundDedup acquires an in-flight claim (processed_at NULL). Stale claims older than 60 s are re-takeable so a process crash does not strand the message_id. - MarkLarkInboundDedupProcessed flips processed_at on durable success (audit row OR chat_message + session touch). - ReleaseLarkInboundDedup deletes the in-flight row on infra failure before any durable side effect, so the retry can re-claim immediately. Dispatcher.Handle now finalizes the claim exactly once based on whether the inner pipeline reached a durable outcome — chat_message commit being the transition point (errors past it Mark, errors before it Release). Regression tests cover the two failure variants Elon flagged plus the inverse invariants (durable-error Marks, drops Mark, in-flight replays drop, stale claims re-claim). Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): owner-fence dedup claim to close the double-write windows (MUL-2671) The two-phase Claim/Mark/Release fix from the previous commit closed the "infra error swallows a replay" gap but left two windows that could still write a chat_message twice for the same Lark message_id: 1. Stale-reclaim race. Worker A claims at t=0, runs slowly past the 60 s staleness TTL but is still alive. Worker B sees the row as stale and re-takes the claim. A reaches AppendUserMessage and commits a second chat_message. 2. Mark window. Worker A commits chat_message but the post-pipeline MarkLarkInboundDedupProcessed fails (DB hiccup) or the process crashes before it runs. 60 s later a retry treats the in-flight row as stale, re-claims it, and writes a second chat_message. Close both with owner fencing + same-tx Mark: - lark_inbound_message_dedup now carries a `claim_token` UUID; ClaimLarkInboundDedup mints a fresh one on insert and on stale re-take, so a reclaim ROTATES the token. - MarkLarkInboundDedupProcessed and ReleaseLarkInboundDedup are fenced on (message_id, claim_token, processed_at IS NULL) and return rowsAffected. Zero means our token is no longer live, and the caller treats it as a no-op (not an error). - AppendUserMessage invokes MarkLarkInboundDedupProcessed INSIDE its chat_message+session tx (qtx). If the token has been rotated by a concurrent reclaim, the Mark matches zero rows and the method returns ErrClaimLost; the deferred Rollback unwinds the chat_message insert, so the other holder is the sole writer. The durable write and the Mark therefore commit (or roll back) atomically — there is no "committed but not yet Marked" window for a crash or retry to exploit. Dispatcher.processClaimed now returns a tri-state dedupFinalize directive (none / mark / release): finalizeNone for the in-tx Mark path (and ErrClaimLost), finalizeMark for audit-drop branches and the defensive post-Append-success fallback, finalizeRelease for pre-durable infra errors. ErrClaimLost is translated into OutcomeDropped + DropReason- Duplicate at the Handle boundary, matching what the WS adapter expects for a "another worker is the writer" outcome. Regression tests: - TestDispatcher_StaleReclaimRaceDoesNotDoubleWrite injects worker B's reclaim via a beforeAppend hook so the claim_token rotates between Claim and AppendUserMessage. Asserts worker A's AppendUserMessage returns ErrClaimLost (no chat_message committed), the dispatcher surfaces a duplicate drop, the token rotated to a value distinct from A's original, and a follow-up replay still duplicate-drops. - TestDispatcher_InTxMarkPreventsPostCommitReclaim verifies the "Mark window" case is unreachable: a successful in-tx Mark produces exactly one Mark call (no post-finalize duplicate), the row is terminal, and a retry with dedupReclaim=true still duplicate-drops without re-rotating the token. - TestDispatcher_InTxMarkSucceedsAndSkipsPostFinalize pins the positive contract: DedupMarked=true must make applyFinalize a no-op (no extra Mark, no Release). fakeQueries gains a fakeDedupRow model carrying (processed, token, rotations) so the test seam matches production's UPDATE-with-WHERE semantics; fakeChat gains a beforeAppend hook to inject race timing. go test ./... and go vet ./... pass. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): real Lark HTTP APIClient for IM v1 send/patch (MUL-2671) Lands the production Lark Open Platform HTTP APIClient that replaces the stub for outbound transport. The patcher's "thinking → streaming → final | error" card lifecycle and the dispatcher's binding-prompt card both now reach Lark for real once MULTICA_LARK_HTTP_ENABLED=true. Scope of this stage: - tenant_access_token retrieval via /open-apis/auth/v3/ tenant_access_token/internal, cached in-process per app_id with a 60s safety margin against Lark's `expire` value. Sub-2-minute expires are clamped to 120s so we never cache an entry that's already past its safe window. - SendInteractiveCard: POST /open-apis/im/v1/messages?receive_id_type=chat_id returning the Lark message_id the Patcher persists in lark_outbound_card_message for later patches. - PatchInteractiveCard: PATCH /open-apis/im/v1/messages/:id with the full re-rendered card body (Lark's update endpoint replaces, not deep-merges). - SendBindingPromptCard: open_id-targeted interactive card with a primary "去绑定" CTA pointing at the redemption URL. Template is co-located with the transport so the dispatcher never has to know about Lark's card schema. - Token-error invalidation: Lark codes 99991663 (expired) / 99991664 (invalid) drop the cached token so the next call refreshes from /tenant_access_token/internal instead of looping on a stale entry. Out of scope (deferred to follow-up stages): - ExchangeOAuthCode stays unimplemented behind ErrAPIClientNotConfigured. The PersonalAgent install handshake's response shape (returning per-installation app credentials in a single call) is not yet verified against the production endpoint, and a silent mis-fill of OAuthExchangeResult would corrupt lark_installation rows past validateExchangeResult. Operators continue to use the manual-paste InstallationService path until the OAuth stage lands. - Inbound WS EventConnector — Hub's ConnectorFactory still needs a real wire-protocol implementation. Wiring: - MULTICA_LARK_HTTP_ENABLED=true switches router.go from the stub to the real client. MULTICA_LARK_HTTP_BASE_URL overrides the default open.feishu.cn host (set to open.larksuite.com for the Lark international tenant, or to an httptest URL for integration tests). - The OAuth handler now also receives the real client (its ExchangeOAuthCode still surfaces ErrAPIClientNotConfigured, so callback behavior is unchanged until that stage lands). Tests (19 new cases against an httptest.Server fake): - happy path send/patch/binding-prompt round trips, asserting URL query params, body shape, Authorization header - token cache: 3 sends share one /tenant_access_token/internal hit - token refresh after clock-driven expiry - sub-margin expire clamping (10s expire → cached for >= safety margin of wall-clock) - Lark error code surfacing (230001 send, 230002 patch, 10003 auth) - token-expired (99991663) invalidates the cache; caller's retry re-fetches and succeeds - non-2xx HTTP status surfaces "http 500: …" - input validation: missing chat_id short-circuits BEFORE auth round-trip, missing card json / open_id / bind url all fail pre-flight without hitting Lark - ExchangeOAuthCode still returns ErrAPIClientNotConfigured - binding-prompt template carries the BindURL and the localized "去绑定" CTA in valid JSON go build ./..., go vet ./..., and go test ./internal/integrations/lark/... pass. Pre-existing handler/router integration tests that require a real Postgres connection are unaffected by this change. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): split outbound vs OAuth-install capability + card update_multi (MUL-2671) Address Elon's two must-fix items from the HEADa09993b1review: 1. HTTP outbound and OAuth-install are now distinct APIClient capabilities. The new SupportsOAuthInstall() reports whether the install flow can succeed end-to-end (i.e. ExchangeOAuthCode is implemented); the real httpAPIClient still returns IsConfigured() = true (send / patch / binding prompt work) but SupportsOAuthInstall() = false until the PersonalAgent install-time response shape is pinned. Handler-side `install_supported` and StartLarkInstall now gate on SupportsOAuthInstall, so a half-wired client never reveals the scan-to-bind UI. larkOAuthErrorReason also maps ErrAPIClientNotConfigured to a dedicated `oauth_exchange_unimplemented` reason so a raw callback hit no longer masquerades as `internal_error`. 2. defaultRenderer now emits config.update_multi=true on every Kind. Lark refuses to apply PatchInteractiveCard to a card whose initial config doesn't declare it shared/updatable, so the absent flag would make every patch after the first send silently no-op on the wire while the local outbound status row still flipped to streaming/final. Tests cover both halves of each fix: - TestHTTPClient_SupportsOAuthInstall_FalseUntilExchangeLands + TestHTTPClient_StubReportsBothCapabilitiesFalse pin the new capability surface. - TestStartLarkInstall_TransportOnlyClientReportsNotConfigured + TestListLarkInstallations_TransportOnlyClientReportsInstallNotSupported pin the handler gate at exactly the half-wired state. - TestLarkOAuthErrorReason_APIClientNotConfigured pins the mapping for both the bare sentinel and the fmt.Errorf-wrapped form HandleCallback produces. - TestDefaultRendererConfigCarriesUpdateMulti covers every CardKind. - TestHTTPClient_(Send|Patch)InteractiveCard_DefaultRendererBodyHasUpdateMulti verify the wire body Lark actually receives carries update_multi through both send and patch transport paths. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): real OAuth code exchange + agent-detail bind entry (MUL-2671) Stages the install side of the MVP critical path on top of the real HTTP outbound work: - httpAPIClient.ExchangeOAuthCode runs the production Lark v2 OAuth flow: POST /authen/v2/oauth/token to swap the authorization code for the installer's open_id, then GET /bot/v3/info under the parent app's tenant_access_token to fetch bot_open_id. Result feeds InstallationParams unchanged so OAuthService.HandleCallback's auto-bind step lights up automatically. - HTTPClientConfig gains OAuthAppID/OAuthAppSecret, read from the same MULTICA_LARK_OAUTH_APP_ID/_APP_SECRET env vars the OAuthConfig consumes. SupportsOAuthInstall now mirrors that pair so the install capability gate is honest: outbound transport without OAuth creds reports configured-but-not-install-supported, exactly like before. - Agent detail inspector wires the LarkAgentBindButton in a new Integrations section, viewer-hidden by canEdit. The button still self-hides when SupportsOAuthInstall is false, so a deployment without OAuth creds renders the section empty rather than CTA-broken. - Capability wording cleaned across handler / router / lark-tab to say "OAuth-install capability" instead of "real APIClient wired", and the misleading TransportOnly... test was renamed/refocused on the early-return branch it actually exercises (Elon non-blocking note). Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): identity-only OAuth + atomic bind (MUL-2671) Addresses Elon's round-4 must-fix items on PR #3277: 1. OAuth v2 token → user_info chain now matches Lark's official user-OAuth shape. `httpAPIClient.ExchangeOAuthCode` POSTs /open-apis/authen/v2/oauth/token (RFC 6749: top-level access_token, NO open_id), then GETs /open-apis/authen/v1/user_info with the user_access_token as Bearer to obtain the installer's open_id / union_id. The test fixture now reflects the real wire shape (separate user_info handler; no synthetic open_id in the token response). 2. `OAuthExchangeResult` is identity-only — drops the synthesized shared-parent AppID / AppSecret / BotOpenID return that broke the UNIQUE(app_id) constraint and the dispatcher's per-app_id routing. `OAuthService.HandleCallback` no longer Upserts an installation row: it looks up the lark_installation already provisioned via the manual-paste POST /lark/installations route and binds the installer onto it. Two new typed errors — ErrInstallationNotProvisioned and ErrInstallationRevoked — map to `installation_not_provisioned` / `installation_revoked` reasons at the HTTP boundary so the UI can guide the admin. The PersonalAgent install API (which would deliver per-installation bot credentials at scan time) remains a follow-up; until it lands the OAuth flow is identity-binding only and the agent-detail bind button stays hidden on deployments without OAuth env (capability gate unchanged). 3. The installation lookup + installer bind run inside a single DB transaction so a concurrent revoke / re-provision between the read and the binding insert cannot leak a half-applied state. `InstallerBinder.BindInstaller` is renamed to `BindInstallerTx` and accepts the OAuth-service-owned transaction's qtx; the binding_token redemption path is unchanged. 4. `validateExchangeResult` is simplified to require only the installer's open_id; the obsolete ErrExchangeMissingAppID / AppSecret / BotOpenID sentinels are removed (no caller can trip them now). The oauth_test suite is rewritten to use a stub failTxStarter so tests covering state-token verification and exchange-error propagation remain DB-free, while a new TestOAuthCallbackOpensTxAfterValidExchange pins the post-must-fix order (state ok + exchange ok ⇒ Begin runs before any lookup or bind, and a Begin failure aborts cleanly with no bind). Verified locally: - go build ./... / go vet ./... clean - go test ./internal/integrations/lark/... ✓ - go test ./internal/handler -run 'Lark|Binding|OAuth' ✓ - go test ./internal/util/secretbox/... ./internal/service/... ✓ Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): device-flow scan-to-install (MUL-2671) Replaces the manual paste-credentials install path + identity-only OAuth callback (rejected in product review: too many steps before a user sees value) with a true single-step scan-to-install built on Lark's RFC 8628 device-flow registration endpoint (POST accounts.feishu.cn/oauth/v1/app/registration) — the same protocol the official larksuite/oapi-sdk-go/scene/registration package and zarazhangrui/feishu-claude-code-bridge use. User journey: admin clicks "Bind to Lark" on the Agent detail page → QR dialog opens → admin scans in the Lark app on their phone → authorizes the new PersonalAgent → dialog auto-closes with the new installation visible. No app_id / app_secret to copy, no Lark developer console visit, no Multica-side OAuth env to configure. Backend (server/internal/integrations/lark): - registration.go — inline ~280-line RFC 8628 client. Begin posts archetype=PersonalAgent / auth_method=client_secret / request_user_info=open_id; Poll follows the upstream SDK's state machine including the tenant-brand mid-stream domain swap to accounts.larksuite.com when a Lark-international account authorizes. SDK is NOT vendored — one endpoint isn't worth dragging the full oapi-sdk-go + transitive deps. - registration_service.go — owns the in-process session store + background polling goroutine. On success calls APIClient.GetBotInfo (the new IM-side endpoint added below) and writes lark_installation + the installer's lark_user_binding inside one DB transaction so a half-applied install can never land. Stable error_reason codes (expired / access_denied / lark_protocol_error / bot_info_failed / installation_conflict / installer_bind_failed / internal_error) drive the UI copy without parsing prose. - client.go / http_client.go — drops ExchangeOAuthCode and SupportsOAuthInstall (no longer applicable: device-flow returns identity alongside credentials in one response); adds GetBotInfo which mints a tenant_access_token from the freshly-minted client_id / client_secret and calls /open-apis/bot/v3/info for the bot_open_id. install_supported now gates on IsConfigured() (real HTTP client wired) instead of a separate OAuth capability. - binding_token.go — absorbs InstallerBindParams / InstallerBinder (previously in oauth.go), retargets the doc-comment from the OAuth caller to the device-flow caller. - Deletes oauth.go + oauth_test.go entirely. Handler & router (server/internal/handler, server/cmd/server): - POST /api/workspaces/{id}/lark/install/begin — opens a new registration session, returns {session_id, qr_code_url, expires_in_seconds, poll_interval_seconds}. Admin-only. - GET /api/workspaces/{id}/lark/install/{sessionId}/status — polling endpoint, returns {status, installation_id?, error_reason?, error_message?}. Workspace-scoped lookup so a stolen session_id cannot be polled from another workspace. Admin-only. - Removes POST /lark/installations (paste form), GET /lark/install/start (OAuth-redirect entry), and GET /api/lark/install/callback (OAuth redirect target). - Removes MULTICA_LARK_OAUTH_APP_ID / _APP_SECRET / _REDIRECT_URI / _STATE_SECRET / _AUTHORIZE_URL / _SUCCESS_URL env vars. Self-host operators no longer need a parent Lark app at all. Frontend (packages/core, packages/views): - New types BeginLarkInstallResponse / LarkInstallStatusResponse + matching API methods (beginLarkInstall / getLarkInstallStatus); drops getLarkInstallURL. - LarkAgentBindButton opens LarkInstallDialog instead of a window.open() to Lark's authorize page. The dialog uses react-qr-code (catalog) to render the verification_uri_complete inline as SVG (no external CDN image), polls status at the server-supplied cadence, auto-closes on success, offers "scan again" on terminal failure. Per CLAUDE.md "Enum drift downgrades, not crashes", error_reason switch has a default fallback so an older desktop build on a newer server still renders the generic failure copy. - Adds the device-flow strings to en + zh-Hans settings.json; removes the obsolete OAuth-not-configured copy. Verified locally: - go build ./... / go vet ./... clean - go test ./internal/integrations/lark/... — all green (existing tests + 15 new registration / GetBotInfo tests) - go test ./internal/handler -run 'Lark|Binding' — all green - pnpm typecheck — all 6 packages clean - pnpm lint — 0 errors (15 pre-existing warnings, none in changed files) - pnpm --filter @multica/views test — 859/859 pass Pre-existing failures in server/internal/middleware (column "profile_description" missing from local test DB) reproduce against the parent commit and are unrelated to this change. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): gate bind CTA to workspace admins, terminate QR polling on 4xx (MUL-2671) Two frontend must-fixes from the PR #3277 二审: 1. LarkAgentBindButton now self-hides for non-admin viewers in addition to the existing install_supported check. The agent-detail page mounts the button under `canEdit`, which canEditAgent lets agent owners through even when they are not workspace admins — but the backend gates POST /lark/install/begin and the status poll on owner/admin (router.go:478-487), so the previous behavior shipped a CTA that was guaranteed to 403. The new gate reads workspace role from the same member list the settings tab already uses. 2. The status polling loop now terminates on 404 (session gone — server restarted, multi-instance routing, or in-process GC swept it) and 403/401 (permission revoked mid-session). Previously every error path scheduled another setTimeout, which trapped the user on a stale QR forever. ApiError gives us the HTTP status verbatim; terminal responses set status=error with stable error_reason codes (session_lost, forbidden) that flow through the existing dialog switch + retry/close affordances. 5xx + network blips still retry. i18n: new install_error_session_lost / install_error_forbidden in en and zh-Hans, with default fallback preserved per the enum-drift rule. Coverage: 6 new vitest cases — admin/owner allow, member deny, unsupported-install deny, and the two terminal-error polling paths using fake timers to assert the loop stops scheduling. Also clears a handful of stale OAuth/manual-install doc comments flagged in the review (non-blocker cleanup): doc.go's §10 now points at RegistrationService, installation.go's input-shape doc loses the OAuth-callback half, and client.go's stubAPIClient comments no longer reference OAuth callbacks. Co-authored-by: multica-agent <github@multica.ai> * docs(integrations/lark): describe gate as device-flow install in agent-detail integrations comment (MUL-2671) The comment block above the agent-detail Integrations section still described the capability gate as 'server-side OAuth-install'. The OAuth path is gone — install is now device-flow per RFC 8628 — so the comment now reads 'server-side device-flow install capability gate'. Pure comment change; behavior is unchanged. Cleans up the nit Elon called out in PR #3277 二审 (MUL-2671). Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): wire inbound pipeline + WS Hub at boot (MUL-2671) Stage 3.a of MUL-2671. Hub class, Dispatcher, ChatSessionService and AuditLogger have all been implemented and tested in prior PRs but none of them was constructed at boot, so the in-process plumbing was never exercised end-to-end. This change wires them together behind the same `MULTICA_LARK_SECRET_KEY` gate that already gates InstallationService / RegistrationService, and starts the Hub under the existing `sweepCtx` so it winds down alongside the other long-running workers after HTTP drain. The real long-conn EventConnector is still pending; the factory hands every supervisor a shared NoopConnector that holds the lease and emits nothing. That lets staging exercise the lease / supervisor / shutdown lifecycle against real DB rows without committing to the Lark wire protocol implementation. Swapping in the real connector is a single line change in the same router block; the Dispatcher / ChatSessionService / Hub seams stay frozen. ## Why a noop placeholder, not a stub-or-skip The Hub's value is mostly its lifecycle: §4.4 ownership lease, LeaseRenewInterval / LeaseTTL, supervisor reap on revoke, clean release on shutdown. None of that runs unless the Hub is actually started. Holding off until the real connector lands means the next PR has to debut both pieces simultaneously; wiring the supervisor loop first lets the real connector PR be a focused, reviewable swap. ## Changes - `internal/integrations/lark/noop_connector.go` — `NoopConnector` implementing `EventConnector`: blocks on ctx until the Hub cancels (lease loss / shutdown / revoke), emits no events, logs on enter/exit so operators see exactly which installation the supervisor is holding the lease for. - `internal/integrations/lark/noop_connector_test.go` — verifies the connector blocks until ctx cancel, returns nil on clean exit, never invokes the emit callback, and the factory shares a single connector instance across installations. - `internal/handler/handler.go` — new `LarkHub *lark.Hub` field on `Handler`. Nil when the Lark integration is disabled. - `cmd/server/router.go` — inside the existing Lark wiring block, construct `AuditLogger`, `ChatSessionService` (with `*pgxpool.Pool` for the in-tx dedup Mark), `Dispatcher` (wiring `h.IssueService` and `h.TaskService` so `/issue`-created issues share counter / duplicate guard / project boundary / broadcast / analytics with the rest of the product), and the `Hub` with the `NoopConnectorFactory`. `NewRouterWithOptions` now returns `(chi.Router, *handler.Handler)` so main.go can drive Hub lifecycle; `NewRouter` discards the handler. - `cmd/server/main.go` — start the Hub under `sweepCtx` after the other background workers, and `Wait` on it after HTTP drain + sweep cancel so the lease renewer can issue a final release before exit. Skipped entirely when `h.LarkHub == nil`. ## Test plan - [x] `go build ./...` clean - [x] `go vet ./...` clean - [x] `go test ./internal/integrations/lark/...` (new noop tests + existing hub / dispatcher / chat_service / registration / binding_token / outbound / issue_command suites) — all pass - [x] `go test ./internal/handler -run 'TestLark|TestRedeemLarkBinding'` pass — handler-side Lark surfaces unchanged - [x] `go test ./internal/service/... ./internal/util/secretbox/...` pass - [x] `pnpm --filter @multica/views exec vitest run settings/components/lark-tab` pass (6/6) — frontend lark surfaces unchanged - [ ] Local broad `go test ./internal/handler/...` still blocked by the pre-existing test DB schema drift Elon flagged in the previous round (`column "metadata" does not exist`, unrelated to this change); CI is the authoritative check. - [ ] Manual end-to-end deferred until the real long-conn EventConnector lands (next stage). MUL-2671 Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): bound Hub lease release + shutdown wait (MUL-2671) Lease release used context.Background(); a stalled DB pool could pin shutdown indefinitely. Add LeaseReleaseTimeout (5s default) and ShutdownTimeout (15s default) to HubConfig, route releaseLease through a bounded context, and expose WaitWithTimeout for main.go so a wedged supervisor degrades to LeaseTTL expiry on the next replica instead of blocking process exit. Also correct the LarkHub field comment in handler.go: the Hub is wired whenever the at-rest secret key is set, independent of whether the outbound HTTP APIClient is configured. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): real WS long-conn connector + ctx-cancel-breaks-read (MUL-2671) Replaces NoopConnectorFactory with a production EventConnector that opens Lark's event-subscription WebSocket. Gated behind MULTICA_LARK_WS_ENABLED so staging boots stay on the noop path until operators opt in, and falls back to noop with a warning when the WS flag is set without MULTICA_LARK_HTTP_ENABLED (the real connector needs the cached tenant_access_token). Why this connector exists separately from the Hub: gorilla/websocket ReadMessage blocks on the underlying TCP socket and does not observe context. The watchdog goroutine inside WSLongConnConnector.Run closes the conn the moment ctx fires, so lease loss / shutdown breaks the blocking read in bounded time — exactly the invariant Hub renewLeaseUntil's runCancel depends on for the "at most one active WS per installation across replicas" guarantee. Tests cover this explicitly (TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked). The Lark wire surface is split into three swappable seams so the transport layer stays tested in isolation: - EndpointFetcher (POST /event-subscription/v1/connection_token) resolves a one-shot wss URL per Run. No caching — replaying a one-shot token would look like a Lark outage. - FrameDecoder turns one raw JSON envelope into an InboundMessage or a "control / heartbeat / drop" verdict. Decoder errors log + drop the frame; they do NOT tear down the connection. - CredentialsProvider wraps InstallationService.DecryptAppSecret so plaintext app_secret lives in memory only during a Run. Also fixes the handler.go LarkHub comment: it still said "joins on Wait during graceful shutdown" but main.go has used WaitWithTimeout (bounded wait) for several commits. Comment now matches. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): align WS to official binary Frame protocol + DispatchResult outbound replies (MUL-2671) Two must-fix items from Elon's review of PR #3277: 1. WS protocol layer rewritten to match the official Lark Go SDK (`larksuite/oapi-sdk-go/v3/ws`): - Bootstrap is `POST /callback/ws/endpoint` with AppID/AppSecret in the body (no tenant_access_token bearer). Response carries wss URL + ClientConfig (PingInterval / ReconnectInterval / ReconnectNonce / ReconnectCount). - `service_id` is parsed from the wss URL query and used as Frame.Service on every outbound frame. - Wire envelope is the binary protobuf `pbbp2.Frame` (hand-rolled via protowire to avoid pulling the whole SDK in, byte-identical field tags). JSON payloads are nested inside Frame.Payload. - Inbound data frames are ACKed with a `Response{code:200,...}` JSON payload that reuses the inbound headers; infra failures produce code=500 so Lark retries. - Ping is the app-layer binary `NewPingFrame(serviceID)` at the server-supplied cadence; WebSocket protocol PING is removed (Lark ignores it). Server-initiated pings get a pong reply. - ctx-cancel-breaks-read invariant preserved via the watchdog goroutine that closes the conn on ctx.Done; the read loop and ping goroutine serialize their writes through a single mutex. 2. `DispatchResult` outbound replies wired via a new `OutcomeReplier`: - `OutcomeNeedsBinding` mints a one-shot binding token and sends the binding prompt card to the sender's open_id. - `OutcomeAgentOffline` / `OutcomeAgentArchived` push a notice card into the chat with the agent name + Chinese copy matching §4.6. - `OutcomeIngested` stays owned by the Patcher; `OutcomeDropped` is silent. - The replier is best-effort: outbound failures are logged and swallowed so a Lark outage cannot stall the inbound pipeline. - Hub installs the noop replier by default; router wires the production `LarkOutcomeReplier` when APIClient.IsConfigured(). PersonalAgent long-conn risk surfaced (open per Feishu docs: `长连接模式仅支持企业自建应用`). The implementation works for any app archetype; the open question is whether `/callback/ws/endpoint` accepts PersonalAgent credentials in practice. Surfacing the Lark code+msg verbatim from the bootstrap response so an operator running the smoke test sees the exact failure rather than a generic timeout. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): byte-compat Frame marshal, chunk reassembly, ACK off reply critical path (MUL-2671) Three protocol blockers from Elon's review of9540008a: 1. Frame.Marshal is now byte-identical to oapi-sdk-go/v3/ws/pbbp2.Frame: - SeqID/LogID/Service/Method (proto2 req) emit unconditionally even at zero - PayloadEncoding/PayloadType/LogIDNew emit unconditionally per gogo generated MarshalToSizedBuffer (no zero-guard) - Payload uses the SDK's `!= nil` guard (nil omits, []byte{} emits 0-length) - ACK payload JSON matches SDK's NewResponseByCode + json.Marshal output ({"code":N,"headers":null,"data":null}) Golden tests pin exact byte sequences for ping/pong/ACK/full/zero frames; verified against the real SDK pbbp2.pb.go MarshalToSizedBuffer producing identical bytes. 2. Multi-frame events (sum>1) are reassembled via the new chunkAssembler: - 5s sliding TTL (matches SDK combine() cache TTL) - Lazy GC on admit (no separate sweeper goroutine) - Out-of-order seq + duplicate seq idempotent - Partial chunks are NOT ACKed (SDK behaviour: only the final chunk's ACK confirms the whole event so Lark can retry on partial loss) - Connector wires assembler per-Run; state dies with the session 3. OutcomeReplier detached from ACK critical path: - HubConfig.ReplyTimeout default 2.5s, strictly under Lark's 3s ACK deadline - handleEvent dispatches synchronously (fast DB path), then spawns the replier under a fresh background ctx with WithTimeout(ReplyTimeout) - Hub.replyWg tracks in-flight replies; Hub.Wait / WaitWithTimeout drain them so shutdown is bounded - Noop replier short-circuits inline (no goroutine cost when outbound APIClient isn't configured) Proof tests: - TestHubScheduleReplyReturnsImmediately: scheduleReply with a 10s slow replier returns in <50ms - TestHubReplyTimeoutCancelsHungReplier: hung replier ctx fires at ReplyTimeout - TestHubWaitDrainsInFlightReplies: Wait blocks until replies finish - TestHubACKNotBlockedByOutboundReply: end-to-end through the connector — data-frame ACK lands within 500ms even when the replier hangs 5s PersonalAgent real-env smoke remains Bohan's decision; this PR closes the technical blockers Elon flagged. Co-authored-by: multica-agent <github@multica.ai> * docs(service/issue): narrow position concurrency claim to create-create (MUL-2671) Elon's review of the merge resolution flagged that the comment on the new NextTopPosition call promised more than the code guarantees: concurrent manual reorder via UpdateIssue(position) does NOT take the workspace row lock that IncrementIssueCounter holds, so a create racing a reorder can still land on the same position. Rewrite the comment to only claim create-create serialization, which is the behaviour the lock actually delivers. No code change. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): keep device-flow polling on RFC 8628 HTTP 400 (MUL-2671) Lark's device-flow polling endpoint returns HTTP 400 with the JSON body `{"error":"authorization_pending"}` while the user hasn't scanned the QR yet — this is the RFC 8628 spec, and the upstream oapi-sdk-go implements the same handling. Our previous doForm treated ANY non-2xx as a terminal protocol error, so every install session was killed by the first poll (~5s after begin) and the install dialog appeared silently empty: the frontend received status=error + lark_protocol_error before the user could even read the description. Fix: doForm now decodes the JSON body first; if it parses, the caller (Begin / Poll) routes on the body's `error` field, where the existing switch correctly maps authorization_pending / slow_down to "keep polling" and access_denied / expired_token to terminal failure. Only unparseable bodies (5xx HTML proxy pages, gateway timeouts) still surface as a typed http_NNN RegistrationError. Three regression tests pin the new behaviour: - HTTP 400 + authorization_pending → res.Status="authorization_pending" - HTTP 400 + access_denied → res.Err.Code="access_denied" (terminal) - HTTP 502 + HTML body → http_502 RegistrationError Verified against the live local env: install/begin -> 200, status stays "pending" through the first poll cycle, no longer flips to "error" within seconds. Co-authored-by: multica-agent <github@multica.ai> * fix(views/lark): reset closedRef on every mount so StrictMode double-mount renders QR (MUL-2671) Empty QR dialog body in the dev env: Bohan opened the bind dialog and got an empty white area where the QR should have been — no QR, no "starting" placeholder, no error text. Backend was returning the QR URL correctly; the bug was on the frontend. Root cause: React 19 / Next.js dev StrictMode mounts every component twice (mount → cleanup → mount). The component instance is REUSED across the simulated remount, which means useRef objects are preserved. The dialog's `closedRef` lifecycle: 1. Mount #1: closedRef={current:false}, beginSession() kicked off (HTTP request still in flight) 2. Cleanup runs: closedRef.current=true 3. Mount #2: beginSession() kicked off again, BUT the ref still reads {current:true} from step 2 4. Both promises resolve. Both hit the post-await guard `if (closedRef.current) return;` and bail out before setSession(). 5. Result: session stays null forever. Every conditional in the dialog body (beginning/session-pending/success/error) is false → empty body. Fix: reset closedRef.current=false at the START of the effect, not just at component construction. The cleanup-then-mount pair now re-arms the guard so subsequent setSession calls actually land. Regression test wraps the dialog in <StrictMode> and asserts the QR appears within 2s with the correct value — fails closed if anyone removes the reset. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): drop EventTaskCompleted subscription so the chat reply doesn't get overwritten by "Done." (MUL-2671) Bohan reproduced on the live dev env: agent replies show only a card saying "Done." in Lark, even though Multica's own chat panel has the real "Hello! I'm cc…" reply. Tasks succeed end-to-end, but the user loses the reply on the Lark side. Root cause: TaskService.CompleteTask publishes two events for every chat task IN ORDER: 1. broadcastChatDone(...) → ChatDonePayload{Content: "Hello!..."} 2. broadcastTaskEvent(Completed) → map[string]any{task_id, agent_id,...} (no `content` key) The Patcher subscribed to BOTH and routed each to finalize(). The first patch correctly rendered the reply text, the second patched the same card with an empty payload — chatDoneContent() returned "" and the renderer fell back to "Done." (default empty-body copy). The second patch wins because Lark stores whatever was last applied. Fix: stop subscribing to EventTaskCompleted in the Patcher and remove the corresponding switch arm. EventChatDone is the canonical "agent finished replying" signal for the Lark card path; EventTaskCompleted is still emitted to the bus for other listeners (web UI, analytics, task usage) where the lack of content doesn't matter. Regression test TestPatcherIgnoresEventTaskCompletedForChatTasks emits ChatDone followed by TaskCompleted on a streaming card and asserts: exactly one patch, body contains the agent reply, body does NOT contain "Done.". If anyone re-adds the EventTaskCompleted subscription, this fails immediately. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): chat replies as plain text IM messages, not card chrome (MUL-2671) Bohan reported on the live dev env that even with the agent's reply shown correctly, every message is wrapped in an interactive card with the agent name as the header — it feels like a system notification, not a normal chat reply. He wants the reply to land as a regular Lark text bubble. Changes: - Add APIClient.SendTextMessage backed by Lark's /open-apis/im/v1/messages with msg_type=text. JSON-encodes the {"text": ...} envelope Lark requires so callers pass raw strings. - Patcher.Register no longer subscribes to EventTaskQueued / EventTaskRunning. There is no more thinking → running → final card lifecycle on the success path: it added card chrome without buying anything for free-form chat. - On EventChatDone, the new sendChatReply path posts the assistant message content as plain text. Empty content is silently dropped rather than rendered as "Done." (the prior fallback that confused Bohan). - Failure path keeps a one-shot error card on EventTaskFailed — the visual distinction from a normal reply is genuinely useful, and failures are rare enough that the chrome isn't noisy. - Throttle / lastPatched map / MinPatchInterval / shouldPatch / markPatched / loadCardOrSkip are all removed; nothing in the new flow patches. Tests: - TestPatcherSendsPlainTextOnChatDone pins the new contract: exactly one SendTextMessage call, no card sends or patches, content matches the ChatDonePayload. - TestPatcherDropsEmptyChatReply pins the "no more Done. fallback" decision — empty content drops, period. - TestPatcherFailEventSendsErrorCard pins the failure path still uses a card (one-shot, no patching). - TestPatcherIgnoresEventTaskCompletedForChatTasks rewritten for text path: ChatDone then TaskCompleted yields exactly one text send, no duplicate. - TestPatcherSkipsWhenNoChatSessionBinding and TestPatcherSwallowsInstallationLoadErrors rewritten to drive EventChatDone (the new entry point) instead of TaskQueued. - TestPatcherSendsThinkingCardOnTaskQueued deleted (no more thinking card). Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): pre-fill PersonalAgent bot name as "<agent> - Multica" (MUL-2823) (#3520) The device-flow install left the bot at Lark's auto-generated "{用户姓名}的智能助手". Lark's registration scene supports pre-filling the name via a `name` query param on the verification/QR URL (mirrors the upstream SDK's AppPreset.Name) — a user-editable default that rides on the QR URL, not the begin POST body (which has no name field). BeginInstall already loads the agent for its ownership check, so we keep it and thread `<agent.Name> - Multica` through Begin → decorateQRCodeURL. A blank name degrades to plain "Multica". There is no post-install rename API (bot/v3 is read-only; no bot/v3/update), so the install-time pre-fill is the only programmatic lever; the user can still edit the name on the creation form. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): restore /issue confirmation + pin SendTextMessage wire (MUL-2671) Two recovered/added contracts off Trump's review of HEADfe381a07: 1) /issue confirmation in Lark was a casualty of the plain-text refactor. The pre-refactor `RenderInput.IssueNumber` field was declared but never actually rendered into the card body, so even in the original card-based flow the user never saw a "Created [MUL-42]" confirmation. Now the OutcomeReplier handles OutcomeIngested + IssueID.Valid by sending a plain text message: Created MUL-42 — fix login bug https://multica.example/issues/MUL-42 Composed from a new DispatchResult.IssueIdentifier + IssueTitle, populated by the Dispatcher from workspace.IssuePrefix + issue.Number / issue.Title. Workspace lookup is best-effort: a Postgres blip on workspace gets a "#42" fallback rather than silently dropping the confirmation. The agent's own chat reply (if any) continues to land separately via the Patcher on EventChatDone — these are two semantically distinct messages and the user benefits from seeing both. 2) SendTextMessage is the wire layer Trump flagged for missing coverage. Three new wire tests pin: - happy path: POST /open-apis/im/v1/messages?receive_id_type=chat_id, msg_type=text, Bearer <tenant_access_token>, double-JSON content envelope - special-character round trip: newlines, double quotes, backslashes, tabs, Chinese + emoji, JSON-lookalike strings. The inner {"text": ...} is encoded once at JSON.Marshal time and once again when the outer body serializes; losing either pass corrupts the message and the bug is invisible without a contract pin. - Lark error path: non-zero `code` surfaces as a wrapped error with the code embedded. Tests: - TestDispatcher_IssueCreationFromCommand asserts IssueIdentifier ("MUL-42") and IssueTitle propagate through DispatchResult. - TestDispatcher_IssueIdentifierFallsBackToNumberOnWorkspaceLookupErr pins the "#7" degrade-graceful fallback. - TestLarkOutcomeReplierIssueCreatedSendsConfirmation pins the text body (identifier + title + deep link) and asserts no card send on this path. - TestLarkOutcomeReplierOutcomeIngestedSilentWithoutIssue pins the silent-on-plain-chat default so we don't accidentally start emitting a confirmation for every message. - TestHTTPClient_SendTextMessage_* covers the wire contract. Frontend locale parity (en + zh-Hans, 53 tests) is currently green on this HEAD; no changes needed. Co-authored-by: multica-agent <github@multica.ai> * fix(views/locales): add missing ko keys for Lark MVP (MUL-2671) Trump flagged on PR #3277 review that the ko bundle was missing the Lark-MVP-only keys that en + zh-Hans both carry. The parity test caught it cleanly after main was merged in (Korean PR landed on main between the prior review and this one): common.lark_bind.* (13 keys) settings.page.tabs.lark (1 key) settings.lark.* (45 keys) agents.inspector.section_integrations (1 key) Korean translations are professional/concise — "Lark" stays as the brand name (matches how en keeps "Lark" + "(飞书)" parenthetically; ko/users searching for the product expect "Lark"), and product copy follows the zh-Hans tone where Multica nouns ("에이전트", "워크스페이스") are romanized loan words consistent with the rest of the ko bundle. Slot ordering preserved against EN: - page.tabs.lark sits between github and integrations - inspector.section_integrations sits right after section_skills Verified: pnpm exec vitest run locales/parity → 105/105 pass. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): /issue origin_type CHECK + Hub restart on credentials rotation (MUL-2671) Two live-env bugs Bohan reproduced: 1) /issue command crashed the WS connector. Dispatcher writes origin_type='lark_chat' on issues born from `/issue`, but the issue_origin_type_check CHECK constraint was last extended in migration 060 for quick_create — it doesn't list lark_chat, so every Lark /issue tripped SQLSTATE 23514 and bubbled up as an infra error. The infra error tore down the WS connector, Lark retried the same message, the new connector tripped the same constraint and crashed again. Repro in the live env: three crashes from the same /issue event over ~40s, each leaving the user with no confirmation in Lark. Migration 111 extends the CHECK list: CHECK (origin_type IN ('autopilot', 'quick_create', 'lark_chat')) 2) Re-scanning an already-bound agent silenced the bot. The device flow re-registers with Lark, which mints a brand-new bot (fresh app_id + app_secret); RegistrationService.finishSuccess upserts into lark_installation by agent_id, so the row's credentials rotate in place. But the running supervisor held the OLD inst struct by value and kept a WS open against the OLD bot's app_id — so all events to the NEW bot went nowhere. Bohan's "claude code 现在不能在飞书里回复了" symptom maps exactly to this: log timeline: 16:29:57 cc connector connected with app_id=cli_aa9398dd... (OLD) 16:34:07 lark registration: install complete (rotation) → row.app_id is now cli_aa93f36f... (NEW) → old WS still subscribed to OLD app_id; new app_id receives nothing Fix: Hub.sweep now compares each installation row's credentials fingerprint (app_id + bot_open_id + sha256(app_secret_encrypted)) against the snapshot the running supervisor was started with. On diff, cancel the old supervisor and start a fresh one inline. A monotonic gen counter on the supervisor entry disambiguates the old goroutine's deferred cleanup from the new entry the rotation path already swapped in. Tests: - TestHubRestartsSupervisorOnCredentialsRotation pins the new path: starts hub on app_one, rotates the row to app_two, asserts the connector factory is called again with the fresh AppID. - TestHubDoesNotRestartSupervisorOnUnchangedRow pins the negative case so an unchanged row doesn't degenerate into a per-sweep busy-loop. - Existing hub tests (lease, supervise, shutdown, ACK timing, noop replier) all green. Verification: - go test ./internal/integrations/lark/... -race -count=1 ok - go build ./... clean - migration applied locally; \d+ issue confirms lark_chat in CHECK Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): per-supervisor lease token to fence rotation handoff (MUL-2671) Elon flagged a race in HEAD be8d4cef's rotation path: both the old and the new supervisors of the same Hub used the hub-wide nodeID as their WS lease token, so an old supervisor's post-cancel releaseLease(nodeID) would CAS-match the lease row the successor had just acquired with the SAME token and DELETE it. Symptom would be a silently empty lease row a few hundred ms after every device-flow re-scan — no replica owning the install, no events delivered, the "bot goes quiet" pattern Bohan hit the first time but now from the fencing side rather than the credentials side. Fix: leaseToken(nodeID, gen) composes "<nodeID>-g<gen>", where gen is the monotonic counter already attached to each supervisorEntry. The nodeID prefix keeps cross-replica observability (an operator inspecting lark_installation.ws_lease_token can still map back to a process) while the -g suffix makes the OLD supervisor's release target the OLD row state. Once the rotation path swaps in the new supervisor, the row's CurrentToken is the new -g(N+1) token, so the old -gN release's WHERE clause no-ops instead of clobbering. acquireLease / renewLeaseUntil / releaseLease now take an explicit token argument; supervise threads its leaseToken through. The plumbing isn't pretty, but having an explicit argument at every call site is the only way the rotation invariant survives subsequent refactors — without it, a future caller could quietly reintroduce "just use h.nodeID" and the race is back. Two regression tests: - TestHubRotationStaleReleaseDoesNotClearSuccessorLease drives the fake lease state machine directly: 1. old acquires(tokenA) 2. rotation lands; new acquires(tokenB) 3. old's stale release(tokenA) fires Asserts owner ends up still tokenB. Hub-wide-nodeID code would fail step 3 by clearing the entry. - TestHubRotationEndToEndKeepsSuccessorLeased runs the same scenario through the live supervise loop: starts hub, rotates the row, waits for sup2 to take over with a distinct token, sleeps past sup1's unwind, asserts the row is still held by a non-sup1 token. Catches the bug even when the goroutine timing is non-deterministic. Verification: go test ./internal/integrations/lark/... -race -count=1 ok go build ./... clean go vet ./... clean Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): route group @-mentions via union_id, not open_id (MUL-2671) In a Lark group with multiple Multica bots installed, the bot whose WS received the event sometimes failed to recognize that it was the @-target while the OTHER bot's supervisor falsely fired. Bohan's controlled three- message test (only @A, only @B, @both) hit this: @A and @B alone went unanswered, @both got picked up by A only. Root cause: the `mentions[].id.open_id` field Lark puts on the WS event is structurally INVERSE to `/bot/v3/info`'s `bot.open_id` across the two WSes. From A's WS perspective, the wire-form open_id for "A was @-ed" is NOT equal to A's API-side open_id, but IS equal to what B's WS sees on its side, and vice versa. The decoder's `mention.open_id == inst.BotOpenID` match therefore fires on the wrong bot in multi-bot groups. Only `union_id` (the Lark-tenant-scoped stable identifier) is consistent across both WSes. Changes: - migration 112 adds nullable `lark_installation.bot_union_id` - sqlc query exposes UpsertLarkInstallation/CreateLarkInstallation with bot_union_id, plus a focused SetLarkInstallationBotUnionID for the backfill path - httpAPIClient.GetBotInfo now follows /bot/v3/info with /contact/v3/ users/{open_id}?user_id_type=open_id and returns both identifiers on BotInfo. Soft-fails on contact-scope denial: install still succeeds with an empty UnionID, and the decoder falls back to the legacy open_id match for single-bot deployments. - RegistrationService.finishSuccess persists union_id alongside open_id during the device-flow finalize. - ws_frame_decoder.containsMention prefers union_id and only walks open_id when the installation row has not been backfilled yet. - BackfillBotUnionIDs runs once at server boot for installations created before migration 112; bounded per-row 10s timeout and a pure soft-fail policy so a slow Lark round-trip cannot block startup. - regression tests cover the three decoder paths: union_id match wins over open_id mismatch, union_id mismatch overrides open_id match, and open_id fallback when union_id is unknown. Co-authored-by: multica-agent <github@multica.ai> * chore: drop trailing blank lines at EOF on four files (MUL-2671) git diff --check origin/main..origin/pr-3277 flagged these as new blank lines at EOF; clearing so the diff stays clean for review. Co-authored-by: multica-agent <github@multica.ai> * fix(views/locales): add missing ja keys for Lark MVP + section_integrations (MUL-2671) CI frontend job tripped on the ja locale parity check: ja is missing the lark_bind block in common.json, the lark block + page.tabs.lark in settings.json, and inspector.section_integrations in agents.json. The ko fix earlier covered Korean; ja was added separately on main and the merge surfaced these gaps. Translations mirror the en source and follow the same voice as the existing ja bundle. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): rewrite @_user_N placeholders into clean body (MUL-2671) When Lark dispatches a group `im.message.receive_v1`, the message text contains opaque `@_user_1`, `@_user_2`, … placeholders and the real identity is in `mentions[]`. We were forwarding the raw text to the agent, so a Bohan-typed "@Bot ping test" arrived as "@_user_1 ping test" — neither human-readable nor useful as LLM context, and the agent was paying tokens to figure out which `@_user_N` was even itself. The new resolveMentions pass: * strips the bot's own mention entirely (the dispatcher already routes the event on AddressedToBot; re-emitting @<self> in front of every message adds zero signal and pollutes context), * substitutes other participants with `@<displayName>` so a follow-up "@Alice" reads naturally, * collapses horizontal whitespace introduced by the strip while preserving original newlines. Bot identity check uses the same union_id-preferred + open_id fallback as containsMention, so the rewrite stays consistent with the routing path. Tests cover the four shapes: bot self-mention, mixed bot + other-user mention, multi-line body with stripped mention, and a no-mention body that should be left untouched. Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): union_id-first self mention strip + token-aware scan + local whitespace cleanup (MUL-2671) Three review blockers on the mention rewrite from PR review: 1. isBotMention now mirrors containsMention's union_id-first policy. When the installation row knows our union_id, we trust it exclusively (open_id is structurally inverted in multi-bot groups — matching on it would re-introduce the routing bug we fixed two commits ago). open_id fallback fires only when union_id is absent. New tests: @-ing both bots in one message correctly strips only self and renders the sibling as @<name>; open_id-matches-but-union_id-differs does NOT strip. 2. resolveMentions no longer collapses or trims whitespace globally. Indentation, tabs, code blocks, tables — all preserved verbatim. When the self mention is removed we eat exactly one adjacent horizontal space (the one after the placeholder, or, when the mention sits at end-of-input, a single space already emitted right before it). New test exercises a multi-line indented + tabbed body and asserts the whole shape survives. 3. Prefix-collision-safe replacement. A chat with 11+ participants exposes both `@_user_1` and `@_user_10`; naive ReplaceAll for `@_user_1` would mangle the substring of `@_user_10`. The resolver now does a single-pass token scan with the mention list sorted longest-key-first, so the longer placeholder always wins at any scan position. New test covers the @_user_1 / @_user_10 case explicitly. Also drops the temporary INFO-level diag logging the previous commit added — root cause was confirmed (union_id swap in the manual backfill; not a decoder bug). Co-authored-by: multica-agent <github@multica.ai> * fix(integrations/lark): scope inbound dedup per (installation_id, message_id) (MUL-2671) Root cause of the residual "@Cc gets dropped as not_addressed_in_group" even after the union_id swap landed: lark_inbound_message_dedup was keyed on `message_id` alone. In a Lark group chat where the workspace has multiple Multica bots installed, Lark delivers the SAME message_id to every bot's WS supervisor. Whichever WS claimed first then ran its own AddressedToBot check; the bot that was actually @-ed lost the dedup race, found the row already terminal (`processed_at IS NOT NULL`), and was dropped as `duplicate` BEFORE it could evaluate its own mention. Net: every @ silently disappeared if Lark happened to route the OTHER bot's WS first. The dedup gate's original purpose (idempotency against WS reconnect replay) is per-installation by definition, so the right key is composite (installation_id, message_id). Changes: - migration 113 drops + recreates lark_inbound_message_dedup with installation_id NOT NULL REFERENCES lark_installation(id) ON DELETE CASCADE and PRIMARY KEY (installation_id, message_id). The table is a 24h transient cache, so dropping existing rows is safe. - sqlc queries: ClaimLarkInboundDedup / MarkLarkInboundDedupProcessed / ReleaseLarkInboundDedup all now take installation_id. - AppendUserMessageParams carries InstallationID through to the in-tx Mark call so the chat_message+dedup atomicity stays intact. - Dispatcher passes inst.ID to claim + applyFinalize + AppendUserMessage. - Test fakes key dedup state on (installation_id, message_id) via a composite map key; all existing pre-seeded rows use a seedDedupKey helper bound to the default activeInstallation fixture so the prior staleness / token-rotation / in-tx mark tests still exercise the same regression they did before. - New regression TestDispatcher_DedupIsScopedPerInstallation pins the multi-bot invariant: a row pre-seeded for installation A does NOT block installation B's first delivery of the same message_id; B runs through its own group-filter / identity / ingest pipeline. Co-authored-by: multica-agent <github@multica.ai> * feat(integrations/lark): render markdown chat replies via schema-2.0 card (MUL-2671) The agent's chat replies were going out as msg_type=text, so every `**bold**`, fenced code block, list, table, and link in the body showed up as literal markdown characters in Lark — the user saw raw asterisks, hashes, pipes instead of formatted text. Bohan reported this and pointed at zarazhangrui/lark-coding-agent-bridge as the shape to emulate. The bridge repo uses Lark interactive cards with the schema-2.0 envelope and a `tag: "markdown"` body element; Lark's client renders that to formatted text (GFM-ish: bold/italic, headings, lists, links, fenced code blocks, tables, blockquotes). They expose multiple reply modes (card / markdown-as-post / text) gated by user config; we go a step simpler — auto-detect markdown syntax in the agent's body and route accordingly: - containsMarkdown(): cheap substring + regex pass for fenced code blocks, headings, list markers, bold/italic, tables, links, blockquotes, horizontal rules, inline code. Biases toward false- positive — wrapping prose in a card still renders fine, but missing a real markdown block leaves raw characters visible. - APIClient gains SendMarkdownCard / SendMarkdownCardParams. Implementation marshals the schema-2.0 envelope verbatim: {schema:"2.0", body:{elements:[{tag:"markdown", content: md}]}}. Stub returns ErrAPIClientNotConfigured. - Patcher.sendChatReply now branches on containsMarkdown: markdown → SendMarkdownCard, plain prose → SendTextMessage. A one-liner "sure, on it" stays as a normal IM bubble (no card chrome); anything with markdown gets the rendered card. Tests: TestContainsMarkdown pins the heuristic across plain prose and ten markdown shapes; TestPatcherRoutesMarkdownReplyToCard and TestPatcherRoutesPlainReplyToText cover the router; new HTTP wire test TestHTTPClient_SendMarkdownCard_HappyPath contract-pins the card envelope (msg_type=interactive, schema 2.0, markdown tag, verbatim body). Full lark suite passes. Co-authored-by: multica-agent <github@multica.ai> * fix(service/issue): route analytics.IssueCreated through obsmetrics.RecordEvent (MUL-2671) CI's TestNoNakedAnalyticsCaptureInHandlersOrServices guard caught the post-merge analytics call in IssueService.captureCreatedAnalytics that still used s.Analytics.Capture(...) directly. Main added that lint to prevent the Prometheus and PostHog sides from drifting — any new analytics.* event must go through obsmetrics.RecordEvent so the business-metrics collector and the PostHog client fire from the same call site. Fix mirrors how TaskService handles it: IssueService gains a Metrics *obsmetrics.BusinessMetrics field (router wires it via h.IssueService.Metrics = opts.BusinessMetrics next to the existing TaskService line), and the in-service Capture call becomes obsmetrics.RecordEvent(s.Analytics, s.Metrics, ...). nil-safe by construction — RecordEvent treats a nil Metrics as PostHog-only. Co-authored-by: multica-agent <github@multica.ai> * feat(views/lark): swap Bind CTA for Connected+Manage link when agent already has an installation (MUL-2671) Bohan reported the agent-detail Bind button keeps inviting the user to re-scan the QR even when the agent already has an active Lark PersonalAgent connected — and re-scanning silently upserts the installation row, leaving the previously-created Lark bot dangling as a zombie. Frustrating UX and an actual product footgun. Anti-zombie guard at the only entry point: LarkAgentBindButton now checks the cached installations listing for an active row pinned to this agent_id. When one exists, the install CTA is gone — replaced by a small Connected pill + an "Manage in Lark" link that opens the Bot's app page in Lark's developer console (open.feishu.cn/app/<app_id>) in a new tab. That's where scopes, display name, and additional permission requests actually live; re-scanning never was the right answer for managing an existing bot. Scoping is per-agent: an active installation on a DIFFERENT agent in the same workspace doesn't affect this agent's button, and a revoked installation falls back to the bind CTA so the user can re-create. Tests cover all four states (own-active / own-revoked / other-agent-active / no-installation) and pin the Manage link's href + target=_blank + noopener. i18n: three new keys in settings.json (en / zh-Hans / ja / ko): agent_bot_connected_label, agent_bot_manage_link, agent_bot_manage_tooltip. Locale parity test still 157/157. The dev console host is hardcoded to open.feishu.cn — operators on the Lark international tenant currently get the wrong host; future-proof fix wants the backend to surface a per-installation dev_console_url on the listings response, called out in a code comment. Co-authored-by: multica-agent <github@multica.ai> * feat(views/settings): collapse Lark into Integrations + render agent identity (MUL-2671) Lark was its own top-level workspace settings tab while Integrations sat empty next to it. As more integrations land, the sidebar would balloon with one tab per provider. Move the Lark surface into Integrations as the first hosted integration; the old ?tab=lark URL redirects through LEGACY_WORKSPACE_TAB_REDIRECTS so bookmarks still resolve. The Connected bots list was leaking the raw Lark app_id (cli_…) as the row title with bot_open_id (ou_…) underneath — meaningless to product users. Since the binding is 1:1 with a Multica Agent, join on agent_id and render the agent's avatar + name via the workspace-standard ActorAvatar + useActorName.getAgentName. Deleted agents fall back to "Unknown Agent" so the row is still actionable for cleanup. Tests: stub useActorName + ActorAvatar in lark-tab.test.tsx and add LarkTab connected-bot tests covering the agent identity render and the deleted-agent fallback. Drop the now-dead integrations.* + page.tabs.lark + lark.bot_open_id_label keys across all four locales — parity still 157/157, views suite 1141/1141. Co-authored-by: multica-agent <github@multica.ai> * feat(views/settings): wrap Lark in a named section inside Integrations (MUL-2671) Integrations is meant to host multiple providers (Slack, Linear etc. as they land), so the Lark content should sit under a Lark heading rather than fill the tab directly — otherwise the first additional integration would feel like it broke the IA. Add a "Lark" / "飞书" section heading above LarkTab using the same h2 chrome the other settings tabs use, and pin lark.section_title across all four locales (parity 169/169). Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: J <j@multica.ai>
3022 lines
98 KiB
Go
3022 lines
98 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issueguard"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// IssueResponse is the JSON response for an issue.
|
|
type IssueResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Number int32 `json:"number"`
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Status string `json:"status"`
|
|
Priority string `json:"priority"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
CreatorType string `json:"creator_type"`
|
|
CreatorID string `json:"creator_id"`
|
|
ParentIssueID *string `json:"parent_issue_id"`
|
|
ProjectID *string `json:"project_id"`
|
|
Position float64 `json:"position"`
|
|
StartDate *string `json:"start_date"`
|
|
DueDate *string `json:"due_date"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
// Metadata is the per-issue KV map (see issue_metadata.go). Always emitted
|
|
// (empty object when unset) so frontend code can `issue.metadata[key]`
|
|
// without nil-guarding the parent field.
|
|
Metadata map[string]any `json:"metadata"`
|
|
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
|
|
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
|
// Labels are bulk-attached by list/detail endpoints so the client can render
|
|
// chips without an N+1 round-trip per row. Pointer + omitempty so paths that
|
|
// don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
|
|
// WS broadcast) emit no `labels` field at all — the client merge then
|
|
// preserves whatever labels are already in cache. nil pointer = "field
|
|
// absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
|
|
Labels *[]LabelResponse `json:"labels,omitempty"`
|
|
}
|
|
|
|
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
|
|
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
|
return IssueResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
Number: i.Number,
|
|
Identifier: identifier,
|
|
Title: i.Title,
|
|
Description: textToPtr(i.Description),
|
|
Status: i.Status,
|
|
Priority: i.Priority,
|
|
AssigneeType: textToPtr(i.AssigneeType),
|
|
AssigneeID: uuidToPtr(i.AssigneeID),
|
|
CreatorType: i.CreatorType,
|
|
CreatorID: uuidToString(i.CreatorID),
|
|
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
|
ProjectID: uuidToPtr(i.ProjectID),
|
|
Position: i.Position,
|
|
StartDate: dateToPtr(i.StartDate),
|
|
DueDate: dateToPtr(i.DueDate),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
UpdatedAt: timestampToString(i.UpdatedAt),
|
|
Metadata: parseIssueMetadata(i.Metadata),
|
|
}
|
|
}
|
|
|
|
// issueListRowToResponse converts a list-query row (no description) to an IssueResponse.
|
|
func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueResponse {
|
|
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
|
return IssueResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
Number: i.Number,
|
|
Identifier: identifier,
|
|
Title: i.Title,
|
|
Description: textToPtr(i.Description),
|
|
Status: i.Status,
|
|
Priority: i.Priority,
|
|
AssigneeType: textToPtr(i.AssigneeType),
|
|
AssigneeID: uuidToPtr(i.AssigneeID),
|
|
CreatorType: i.CreatorType,
|
|
CreatorID: uuidToString(i.CreatorID),
|
|
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
|
ProjectID: uuidToPtr(i.ProjectID),
|
|
Position: i.Position,
|
|
StartDate: dateToPtr(i.StartDate),
|
|
DueDate: dateToPtr(i.DueDate),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
UpdatedAt: timestampToString(i.UpdatedAt),
|
|
Metadata: parseIssueMetadata(i.Metadata),
|
|
}
|
|
}
|
|
|
|
// labelsByIssue bulk-loads labels for the given issue IDs and returns a map
|
|
// keyed by issue UUID string. On error or empty input, returns an empty map —
|
|
// label rendering is non-critical and we'd rather serve issues without labels
|
|
// than fail the whole list call.
|
|
func (h *Handler) labelsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) map[string][]LabelResponse {
|
|
out := map[string][]LabelResponse{}
|
|
if len(issueIDs) == 0 {
|
|
return out
|
|
}
|
|
rows, err := h.Queries.ListLabelsForIssues(ctx, db.ListLabelsForIssuesParams{
|
|
IssueIds: issueIDs,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("ListLabelsForIssues failed", "error", err)
|
|
return out
|
|
}
|
|
for _, r := range rows {
|
|
issueID := uuidToString(r.IssueID)
|
|
out[issueID] = append(out[issueID], LabelResponse{
|
|
ID: uuidToString(r.ID),
|
|
WorkspaceID: uuidToString(r.WorkspaceID),
|
|
Name: r.Name,
|
|
Color: r.Color,
|
|
CreatedAt: timestampToString(r.CreatedAt),
|
|
UpdatedAt: timestampToString(r.UpdatedAt),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
|
|
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
|
return IssueResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
Number: i.Number,
|
|
Identifier: identifier,
|
|
Title: i.Title,
|
|
Description: textToPtr(i.Description),
|
|
Status: i.Status,
|
|
Priority: i.Priority,
|
|
AssigneeType: textToPtr(i.AssigneeType),
|
|
AssigneeID: uuidToPtr(i.AssigneeID),
|
|
CreatorType: i.CreatorType,
|
|
CreatorID: uuidToString(i.CreatorID),
|
|
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
|
ProjectID: uuidToPtr(i.ProjectID),
|
|
Position: i.Position,
|
|
StartDate: dateToPtr(i.StartDate),
|
|
DueDate: dateToPtr(i.DueDate),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
UpdatedAt: timestampToString(i.UpdatedAt),
|
|
Metadata: parseIssueMetadata(i.Metadata),
|
|
}
|
|
}
|
|
|
|
type IssueAssigneeGroupResponse struct {
|
|
ID string `json:"id"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Issues []IssueResponse `json:"issues"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
type GroupedIssuesResponse struct {
|
|
Groups []IssueAssigneeGroupResponse `json:"groups"`
|
|
}
|
|
|
|
type groupedIssueRow struct {
|
|
db.ListIssuesRow
|
|
GroupTotal int64
|
|
}
|
|
|
|
func assigneeGroupID(assigneeType pgtype.Text, assigneeID pgtype.UUID) string {
|
|
if assigneeType.Valid && assigneeID.Valid {
|
|
return "assignee:" + assigneeType.String + ":" + uuidToString(assigneeID)
|
|
}
|
|
return "assignee:unassigned"
|
|
}
|
|
|
|
// SearchIssueResponse extends IssueResponse with search metadata.
|
|
type SearchIssueResponse struct {
|
|
IssueResponse
|
|
MatchSource string `json:"match_source"`
|
|
MatchedSnippet *string `json:"matched_snippet,omitempty"`
|
|
MatchedDescriptionSnippet *string `json:"matched_description_snippet,omitempty"`
|
|
MatchedCommentSnippet *string `json:"matched_comment_snippet,omitempty"`
|
|
}
|
|
|
|
// extractSnippet extracts a snippet of text around the first occurrence of query.
|
|
// Returns up to ~120 runes centered on the match. Uses rune-based slicing to
|
|
// avoid splitting multi-byte UTF-8 characters (important for CJK content).
|
|
// For multi-word queries, tries phrase match first; if not found, locates the
|
|
// earliest occurring individual term and centers the snippet around it.
|
|
func extractSnippet(content, query string) string {
|
|
runes := []rune(content)
|
|
lowerRunes := []rune(strings.ToLower(content))
|
|
queryRunes := []rune(strings.ToLower(query))
|
|
|
|
idx := findRuneSubstring(lowerRunes, queryRunes)
|
|
|
|
// If phrase not found, try individual terms for multi-word queries.
|
|
matchLen := len(queryRunes)
|
|
if idx < 0 {
|
|
terms := strings.Fields(strings.ToLower(query))
|
|
if len(terms) > 1 {
|
|
earliest := -1
|
|
earliestLen := 0
|
|
for _, term := range terms {
|
|
termRunes := []rune(term)
|
|
pos := findRuneSubstring(lowerRunes, termRunes)
|
|
if pos >= 0 && (earliest < 0 || pos < earliest) {
|
|
earliest = pos
|
|
earliestLen = len(termRunes)
|
|
}
|
|
}
|
|
if earliest >= 0 {
|
|
idx = earliest
|
|
matchLen = earliestLen
|
|
}
|
|
}
|
|
}
|
|
|
|
if idx < 0 {
|
|
if len(runes) > 120 {
|
|
return string(runes[:120]) + "..."
|
|
}
|
|
return content
|
|
}
|
|
start := idx - 40
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := idx + matchLen + 80
|
|
if end > len(runes) {
|
|
end = len(runes)
|
|
}
|
|
snippet := string(runes[start:end])
|
|
if start > 0 {
|
|
snippet = "..." + snippet
|
|
}
|
|
if end < len(runes) {
|
|
snippet = snippet + "..."
|
|
}
|
|
return snippet
|
|
}
|
|
|
|
// findRuneSubstring returns the index of needle in haystack, or -1 if not found.
|
|
func findRuneSubstring(haystack, needle []rune) int {
|
|
if len(needle) == 0 || len(haystack) < len(needle) {
|
|
return -1
|
|
}
|
|
for i := 0; i <= len(haystack)-len(needle); i++ {
|
|
match := true
|
|
for j := range needle {
|
|
if haystack[i+j] != needle[j] {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// descriptionContains checks if the description text contains the search phrase or all terms.
|
|
func descriptionContains(desc pgtype.Text, phrase string, terms []string) bool {
|
|
if !desc.Valid || desc.String == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(desc.String)
|
|
if strings.Contains(lower, strings.ToLower(phrase)) {
|
|
return true
|
|
}
|
|
if len(terms) > 1 {
|
|
for _, t := range terms {
|
|
if !strings.Contains(lower, strings.ToLower(t)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// escapeLike escapes LIKE special characters (%, _, \) in user input.
|
|
func escapeLike(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
|
return s
|
|
}
|
|
|
|
// splitSearchTerms splits a query into individual search terms, filtering empty strings.
|
|
func splitSearchTerms(q string) []string {
|
|
fields := strings.FieldsFunc(q, func(r rune) bool {
|
|
return unicode.IsSpace(r)
|
|
})
|
|
terms := make([]string, 0, len(fields))
|
|
for _, f := range fields {
|
|
if f != "" {
|
|
terms = append(terms, f)
|
|
}
|
|
}
|
|
return terms
|
|
}
|
|
|
|
// identifierNumberRe matches patterns like "MUL-123" or "ABC-45".
|
|
var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z]+-(\d+)$`)
|
|
|
|
// parseQueryNumber extracts an issue number from the query if it looks like
|
|
// an identifier (e.g. "MUL-123") or a bare number (e.g. "123").
|
|
func parseQueryNumber(q string) (int, bool) {
|
|
q = strings.TrimSpace(q)
|
|
// Check for identifier pattern like "MUL-123"
|
|
if m := identifierNumberRe.FindStringSubmatch(q); m != nil {
|
|
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
|
|
return n, true
|
|
}
|
|
}
|
|
// Check for bare number
|
|
if n, err := strconv.Atoi(q); err == nil && n > 0 {
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// searchResult holds a raw row from the dynamic search query.
|
|
type searchResult struct {
|
|
issue db.Issue
|
|
totalCount int64
|
|
matchSource string
|
|
matchedCommentContent string
|
|
}
|
|
|
|
// buildSearchQuery builds a dynamic SQL query for issue search.
|
|
// It uses LOWER(column) LIKE for case-insensitive matching compatible with pg_bigm 1.2 GIN indexes.
|
|
// Search patterns are lowercased in Go to avoid redundant LOWER() on the pattern side in SQL.
|
|
// LIKE patterns are pre-built in Go (e.g. "%html%") so pg_bigm can extract bigrams from a single parameter value.
|
|
func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool) (string, []any) {
|
|
// Lowercase in Go so SQL only needs LOWER() on the column side.
|
|
phrase = strings.ToLower(phrase)
|
|
for i, t := range terms {
|
|
terms[i] = strings.ToLower(t)
|
|
}
|
|
|
|
// Parameter index tracker
|
|
argIdx := 1
|
|
args := []any{}
|
|
nextArg := func(val any) string {
|
|
args = append(args, val)
|
|
s := fmt.Sprintf("$%d", argIdx)
|
|
argIdx++
|
|
return s
|
|
}
|
|
|
|
escapedPhrase := escapeLike(phrase)
|
|
// $1: exact phrase (for exact title match)
|
|
phraseParam := nextArg(escapedPhrase)
|
|
// $2: "%phrase%" (contains pattern — pre-built for pg_bigm index usage)
|
|
phraseContainsParam := nextArg("%" + escapedPhrase + "%")
|
|
// $3: "phrase%" (starts-with pattern)
|
|
phraseStartsWithParam := nextArg(escapedPhrase + "%")
|
|
|
|
wsParam := nextArg(nil) // $4 — workspace_id, will be filled by caller position
|
|
|
|
// Build per-term LIKE conditions only for multi-word search.
|
|
var termContainsParams []string
|
|
if len(terms) > 1 {
|
|
for _, t := range terms {
|
|
et := escapeLike(t)
|
|
termContainsParams = append(termContainsParams, nextArg("%"+et+"%"))
|
|
}
|
|
}
|
|
|
|
// --- WHERE clause ---
|
|
var whereParts []string
|
|
|
|
// Full phrase match: title, description, or comment
|
|
phraseMatch := fmt.Sprintf(
|
|
"(LOWER(i.title) LIKE %s OR LOWER(COALESCE(i.description, '')) LIKE %s OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND LOWER(c.content) LIKE %s))",
|
|
phraseContainsParam, phraseContainsParam, phraseContainsParam,
|
|
)
|
|
whereParts = append(whereParts, phraseMatch)
|
|
|
|
// Multi-word AND match (each term must appear somewhere)
|
|
if len(termContainsParams) > 1 {
|
|
var termConditions []string
|
|
for _, tp := range termContainsParams {
|
|
termConditions = append(termConditions, fmt.Sprintf(
|
|
"(LOWER(i.title) LIKE %s OR LOWER(COALESCE(i.description, '')) LIKE %s OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND LOWER(c.content) LIKE %s))",
|
|
tp, tp, tp,
|
|
))
|
|
}
|
|
whereParts = append(whereParts, "("+strings.Join(termConditions, " AND ")+")")
|
|
}
|
|
|
|
// Number match
|
|
numParam := ""
|
|
if hasNum {
|
|
numParam = nextArg(queryNum)
|
|
whereParts = append(whereParts, fmt.Sprintf("i.number = %s", numParam))
|
|
}
|
|
|
|
whereClause := "(" + strings.Join(whereParts, " OR ") + ")"
|
|
|
|
if !includeClosed {
|
|
whereClause += " AND i.status NOT IN ('done', 'cancelled')"
|
|
}
|
|
|
|
// --- ORDER BY clause ---
|
|
// Build ranking CASE with fine-grained tiers.
|
|
var rankCases []string
|
|
|
|
// Tier 0: Identifier exact match
|
|
if hasNum {
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN i.number = %s THEN 0", numParam))
|
|
}
|
|
|
|
// Tier 1: Exact title match
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(i.title) = %s THEN 1", phraseParam))
|
|
|
|
// Tier 2: Title starts with phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(i.title) LIKE %s THEN 2", phraseStartsWithParam))
|
|
|
|
// Tier 3: Title contains phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(i.title) LIKE %s THEN 3", phraseContainsParam))
|
|
|
|
// Tier 4: Title matches all words (multi-word only)
|
|
if len(termContainsParams) > 1 {
|
|
var titleTerms []string
|
|
for _, tp := range termContainsParams {
|
|
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(i.title) LIKE %s", tp))
|
|
}
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 4", strings.Join(titleTerms, " AND ")))
|
|
}
|
|
|
|
// Tier 5: Description contains phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(COALESCE(i.description, '')) LIKE %s THEN 5", phraseContainsParam))
|
|
|
|
// Tier 6: Description matches all words (multi-word only)
|
|
if len(termContainsParams) > 1 {
|
|
var descTerms []string
|
|
for _, tp := range termContainsParams {
|
|
descTerms = append(descTerms, fmt.Sprintf("LOWER(COALESCE(i.description, '')) LIKE %s", tp))
|
|
}
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 6", strings.Join(descTerms, " AND ")))
|
|
}
|
|
|
|
// Tier 7: Comment contains phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND LOWER(c.content) LIKE %s) THEN 7", phraseContainsParam))
|
|
|
|
// Tier 8: Comment matches all words (multi-word only)
|
|
if len(termContainsParams) > 1 {
|
|
var commentTerms []string
|
|
for _, tp := range termContainsParams {
|
|
commentTerms = append(commentTerms, fmt.Sprintf("LOWER(c.content) LIKE %s", tp))
|
|
}
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND (%s)) THEN 8", strings.Join(commentTerms, " AND ")))
|
|
}
|
|
|
|
rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 9 END"
|
|
|
|
// Status priority: active issues first
|
|
statusRank := `CASE i.status
|
|
WHEN 'in_progress' THEN 0
|
|
WHEN 'in_review' THEN 1
|
|
WHEN 'todo' THEN 2
|
|
WHEN 'blocked' THEN 3
|
|
WHEN 'backlog' THEN 4
|
|
WHEN 'done' THEN 5
|
|
WHEN 'cancelled' THEN 6
|
|
ELSE 7
|
|
END`
|
|
|
|
// --- match_source expression ---
|
|
matchSourceExpr := fmt.Sprintf(`CASE
|
|
WHEN LOWER(i.title) LIKE %s THEN 'title'
|
|
WHEN LOWER(COALESCE(i.description, '')) LIKE %s THEN 'description'
|
|
ELSE 'comment'
|
|
END`, phraseContainsParam, phraseContainsParam)
|
|
|
|
// For multi-word: also check if all terms match in title/description
|
|
if len(termContainsParams) > 1 {
|
|
var titleTerms []string
|
|
var descTerms []string
|
|
for _, tp := range termContainsParams {
|
|
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(i.title) LIKE %s", tp))
|
|
descTerms = append(descTerms, fmt.Sprintf("LOWER(COALESCE(i.description, '')) LIKE %s", tp))
|
|
}
|
|
matchSourceExpr = fmt.Sprintf(`CASE
|
|
WHEN LOWER(i.title) LIKE %s THEN 'title'
|
|
WHEN (%s) THEN 'title'
|
|
WHEN LOWER(COALESCE(i.description, '')) LIKE %s THEN 'description'
|
|
WHEN (%s) THEN 'description'
|
|
ELSE 'comment'
|
|
END`,
|
|
phraseContainsParam, strings.Join(titleTerms, " AND "),
|
|
phraseContainsParam, strings.Join(descTerms, " AND "),
|
|
)
|
|
}
|
|
|
|
// --- matched_comment_content subquery ---
|
|
// Always return matching comment content regardless of match_source,
|
|
// so frontend can display comment snippet alongside title/description matches.
|
|
commentSubquery := fmt.Sprintf(`COALESCE(
|
|
(SELECT c.content FROM comment c
|
|
WHERE c.issue_id = i.id AND LOWER(c.content) LIKE %s
|
|
ORDER BY c.created_at DESC LIMIT 1),
|
|
''
|
|
)`, phraseContainsParam)
|
|
|
|
if len(termContainsParams) > 1 {
|
|
var commentTerms []string
|
|
for _, tp := range termContainsParams {
|
|
commentTerms = append(commentTerms, fmt.Sprintf("LOWER(c.content) LIKE %s", tp))
|
|
}
|
|
commentSubquery = fmt.Sprintf(`COALESCE(
|
|
(SELECT c.content FROM comment c
|
|
WHERE c.issue_id = i.id AND (LOWER(c.content) LIKE %s OR (%s))
|
|
ORDER BY c.created_at DESC LIMIT 1),
|
|
''
|
|
)`, phraseContainsParam, strings.Join(commentTerms, " AND "))
|
|
}
|
|
|
|
limitParam := nextArg(nil) // placeholder
|
|
offsetParam := nextArg(nil) // placeholder
|
|
|
|
query := fmt.Sprintf(`SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
|
|
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
|
|
i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position,
|
|
i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id,
|
|
COUNT(*) OVER() AS total_count,
|
|
%s AS match_source,
|
|
%s AS matched_comment_content
|
|
FROM issue i
|
|
WHERE i.workspace_id = %s AND %s
|
|
ORDER BY %s, %s, i.updated_at DESC
|
|
LIMIT %s OFFSET %s`,
|
|
matchSourceExpr,
|
|
commentSubquery,
|
|
wsParam,
|
|
whereClause,
|
|
rankExpr,
|
|
statusRank,
|
|
limitParam,
|
|
offsetParam,
|
|
)
|
|
|
|
return query, args
|
|
}
|
|
|
|
func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
q := r.URL.Query().Get("q")
|
|
if q == "" {
|
|
writeError(w, http.StatusBadRequest, "q parameter is required")
|
|
return
|
|
}
|
|
|
|
limit := 20
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
includeClosed := r.URL.Query().Get("include_closed") == "true"
|
|
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
terms := splitSearchTerms(q)
|
|
queryNum, hasNum := parseQueryNumber(q)
|
|
|
|
sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed)
|
|
// Fill placeholder args: $4 = workspace_id, last two = limit, offset
|
|
args[3] = wsUUID
|
|
args[len(args)-2] = limit
|
|
args[len(args)-1] = offset
|
|
|
|
rows, err := h.DB.Query(ctx, sqlQuery, args...)
|
|
if err != nil {
|
|
slog.Warn("search issues failed", "error", err, "workspace_id", workspaceID, "query", q)
|
|
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []searchResult
|
|
for rows.Next() {
|
|
var sr searchResult
|
|
if err := rows.Scan(
|
|
&sr.issue.ID,
|
|
&sr.issue.WorkspaceID,
|
|
&sr.issue.Title,
|
|
&sr.issue.Description,
|
|
&sr.issue.Status,
|
|
&sr.issue.Priority,
|
|
&sr.issue.AssigneeType,
|
|
&sr.issue.AssigneeID,
|
|
&sr.issue.CreatorType,
|
|
&sr.issue.CreatorID,
|
|
&sr.issue.ParentIssueID,
|
|
&sr.issue.AcceptanceCriteria,
|
|
&sr.issue.ContextRefs,
|
|
&sr.issue.Position,
|
|
&sr.issue.StartDate,
|
|
&sr.issue.DueDate,
|
|
&sr.issue.CreatedAt,
|
|
&sr.issue.UpdatedAt,
|
|
&sr.issue.Number,
|
|
&sr.issue.ProjectID,
|
|
&sr.totalCount,
|
|
&sr.matchSource,
|
|
&sr.matchedCommentContent,
|
|
); err != nil {
|
|
slog.Warn("search issues scan failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
|
return
|
|
}
|
|
results = append(results, sr)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("search issues rows error", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
|
return
|
|
}
|
|
|
|
var total int64
|
|
if len(results) > 0 {
|
|
total = results[0].totalCount
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
resp := make([]SearchIssueResponse, len(results))
|
|
for i, sr := range results {
|
|
sir := SearchIssueResponse{
|
|
IssueResponse: issueToResponse(sr.issue, prefix),
|
|
MatchSource: sr.matchSource,
|
|
}
|
|
// Always populate comment snippet when a matching comment exists
|
|
if sr.matchedCommentContent != "" {
|
|
snippet := extractSnippet(sr.matchedCommentContent, q)
|
|
sir.MatchedCommentSnippet = &snippet
|
|
// Keep backward compat: also set MatchedSnippet for comment-source matches
|
|
if sr.matchSource == "comment" {
|
|
sir.MatchedSnippet = &snippet
|
|
}
|
|
}
|
|
// Populate description snippet when description matches
|
|
if sr.matchSource == "description" || descriptionContains(sr.issue.Description, q, terms) {
|
|
if sr.issue.Description.Valid && sr.issue.Description.String != "" {
|
|
snippet := extractSnippet(sr.issue.Description.String, q)
|
|
sir.MatchedDescriptionSnippet = &snippet
|
|
}
|
|
}
|
|
resp[i] = sir
|
|
}
|
|
|
|
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Parse optional filter params. Malformed UUIDs in filters return 400 —
|
|
// silently coercing them to a zero UUID would mask a client bug and let
|
|
// the query return an empty result set (or worse, match a NULL row).
|
|
var priorityFilter pgtype.Text
|
|
if p := r.URL.Query().Get("priority"); p != "" {
|
|
priorityFilter = pgtype.Text{String: p, Valid: true}
|
|
}
|
|
var assigneeFilter pgtype.UUID
|
|
if a := r.URL.Query().Get("assignee_id"); a != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, a, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
assigneeFilter = id
|
|
}
|
|
var assigneeIdsFilter []pgtype.UUID
|
|
if ids := r.URL.Query().Get("assignee_ids"); ids != "" {
|
|
for _, raw := range strings.Split(ids, ",") {
|
|
if s := strings.TrimSpace(raw); s != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
assigneeIdsFilter = append(assigneeIdsFilter, id)
|
|
}
|
|
}
|
|
}
|
|
var creatorFilter pgtype.UUID
|
|
if c := r.URL.Query().Get("creator_id"); c != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, c, "creator_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
creatorFilter = id
|
|
}
|
|
var projectFilter pgtype.UUID
|
|
if p := r.URL.Query().Get("project_id"); p != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, p, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
projectFilter = id
|
|
}
|
|
// involves_user_id widens the assignee filter to surface issues where the
|
|
// user is the indirect assignee (their owned agent, or a squad they belong
|
|
// to / lead / have an agent inside). Direct member-assignment is excluded
|
|
// by design — that is the meaning of `assignee_id` (tab 1), and tab 3 must
|
|
// be disjoint from tab 1.
|
|
var involvesUserFilter pgtype.UUID
|
|
if u := r.URL.Query().Get("involves_user_id"); u != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, u, "involves_user_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
involvesUserFilter = id
|
|
}
|
|
|
|
metadataFilter, ok := parseMetadataFilterParam(w, r.URL.Query().Get("metadata"))
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// open_only=true returns all non-done/cancelled issues (no limit).
|
|
if r.URL.Query().Get("open_only") == "true" {
|
|
issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
|
|
WorkspaceID: wsUUID,
|
|
Priority: priorityFilter,
|
|
AssigneeID: assigneeFilter,
|
|
AssigneeIds: assigneeIdsFilter,
|
|
CreatorID: creatorFilter,
|
|
ProjectID: projectFilter,
|
|
InvolvesUserID: involvesUserFilter,
|
|
MetadataFilter: metadataFilter,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
ids := make([]pgtype.UUID, len(issues))
|
|
for i, issue := range issues {
|
|
ids[i] = issue.ID
|
|
}
|
|
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
|
|
resp := make([]IssueResponse, len(issues))
|
|
for i, issue := range issues {
|
|
resp[i] = openIssueRowToResponse(issue, prefix)
|
|
labels := labelsMap[resp[i].ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
resp[i].Labels = &labels
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
"total": len(resp),
|
|
})
|
|
return
|
|
}
|
|
|
|
limit := 100
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
var statusFilter pgtype.Text
|
|
if s := r.URL.Query().Get("status"); s != "" {
|
|
statusFilter = pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
// scheduled=true restricts the result to issues that have at least one of
|
|
// start_date / due_date set. Used by the Project Gantt view, which only
|
|
// renders schedulable rows and shouldn't pay for the full project list.
|
|
var scheduledFilter pgtype.Bool
|
|
if r.URL.Query().Get("scheduled") == "true" {
|
|
scheduledFilter = pgtype.Bool{Bool: true, Valid: true}
|
|
}
|
|
|
|
// Parse sort and direction params for dynamic ORDER BY.
|
|
// Manual sort (position) is always ASC — direction is ignored because
|
|
// the user defines order through drag-and-drop, reversing it has no
|
|
// product meaning.
|
|
sortCol := "position"
|
|
if s := r.URL.Query().Get("sort"); s != "" {
|
|
switch s {
|
|
case "position", "title", "created_at", "start_date", "due_date":
|
|
sortCol = s
|
|
case "priority":
|
|
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid sort value")
|
|
return
|
|
}
|
|
}
|
|
sortDir := "ASC"
|
|
if sortCol != "position" {
|
|
if d := r.URL.Query().Get("direction"); d != "" {
|
|
switch strings.ToLower(d) {
|
|
case "asc":
|
|
sortDir = "ASC"
|
|
case "desc":
|
|
sortDir = "DESC"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid direction value")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build dynamic SQL — same approach as ListGroupedIssues.
|
|
where := []string{"i.workspace_id = $1"}
|
|
args := []any{wsUUID}
|
|
addArg := func(v any) string {
|
|
args = append(args, v)
|
|
return "$" + strconv.Itoa(len(args))
|
|
}
|
|
|
|
if statusFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.status = %s", addArg(statusFilter.String)))
|
|
}
|
|
if priorityFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.priority = %s", addArg(priorityFilter.String)))
|
|
}
|
|
if assigneeFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(assigneeFilter)))
|
|
}
|
|
if len(assigneeIdsFilter) > 0 {
|
|
where = append(where, fmt.Sprintf("i.assignee_id = ANY(%s::uuid[])", addArg(assigneeIdsFilter)))
|
|
}
|
|
if creatorFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.creator_id = %s::uuid", addArg(creatorFilter)))
|
|
}
|
|
if projectFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(projectFilter)))
|
|
}
|
|
if scheduledFilter.Valid {
|
|
where = append(where, "(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)")
|
|
}
|
|
if metadataFilter != nil {
|
|
where = append(where, fmt.Sprintf("i.metadata @> %s::jsonb", addArg(string(metadataFilter))))
|
|
}
|
|
if involvesUserFilter.Valid {
|
|
ref := addArg(involvesUserFilter)
|
|
where = append(where, fmt.Sprintf(`(
|
|
(i.assignee_type = 'agent' AND i.assignee_id IN (
|
|
SELECT a.id FROM agent a
|
|
WHERE a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'member'
|
|
AND sm.member_id = %[1]s::uuid
|
|
UNION
|
|
SELECT s.id
|
|
FROM squad s
|
|
JOIN agent a ON a.id = s.leader_id
|
|
WHERE s.workspace_id = $1
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
UNION
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
JOIN agent a ON a.id = sm.member_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'agent'
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
)`, ref))
|
|
}
|
|
|
|
whereSql := strings.Join(where, " AND ")
|
|
|
|
// Build ORDER BY clause.
|
|
orderBy := sortCol
|
|
if !strings.HasPrefix(sortCol, "CASE") {
|
|
orderBy = "i." + sortCol
|
|
}
|
|
orderBy += " " + sortDir
|
|
if sortCol == "start_date" || sortCol == "due_date" {
|
|
orderBy += " NULLS LAST"
|
|
}
|
|
orderBy += ", i.created_at DESC"
|
|
|
|
offsetRef := addArg(int64(offset))
|
|
limitRef := addArg(int64(limit))
|
|
|
|
query := fmt.Sprintf(`SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
|
|
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
|
|
i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata
|
|
FROM issue i
|
|
WHERE %s
|
|
ORDER BY %s
|
|
LIMIT %s OFFSET %s`, whereSql, orderBy, limitRef, offsetRef)
|
|
|
|
rows, err := h.DB.Query(ctx, query, args...)
|
|
if err != nil {
|
|
slog.Warn("ListIssues query failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var issues []db.ListIssuesRow
|
|
for rows.Next() {
|
|
var row db.ListIssuesRow
|
|
if err := rows.Scan(
|
|
&row.ID,
|
|
&row.WorkspaceID,
|
|
&row.Title,
|
|
&row.Description,
|
|
&row.Status,
|
|
&row.Priority,
|
|
&row.AssigneeType,
|
|
&row.AssigneeID,
|
|
&row.CreatorType,
|
|
&row.CreatorID,
|
|
&row.ParentIssueID,
|
|
&row.Position,
|
|
&row.StartDate,
|
|
&row.DueDate,
|
|
&row.CreatedAt,
|
|
&row.UpdatedAt,
|
|
&row.Number,
|
|
&row.ProjectID,
|
|
&row.Metadata,
|
|
); err != nil {
|
|
slog.Warn("ListIssues scan failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
issues = append(issues, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("ListIssues rows failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
|
|
// Get the true total count for pagination awareness.
|
|
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM issue i WHERE %s`, whereSql)
|
|
// Count query uses the same args minus the OFFSET and LIMIT params (last two added).
|
|
countArgs := args[:len(args)-2]
|
|
var total int64
|
|
if err := h.DB.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
|
total = int64(len(issues))
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
ids := make([]pgtype.UUID, len(issues))
|
|
for i, issue := range issues {
|
|
ids[i] = issue.ID
|
|
}
|
|
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
|
|
resp := make([]IssueResponse, len(issues))
|
|
for i, issue := range issues {
|
|
resp[i] = issueListRowToResponse(issue, prefix)
|
|
labels := labelsMap[resp[i].ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
resp[i].Labels = &labels
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
type issueActorFilter struct {
|
|
actorType string
|
|
actorID pgtype.UUID
|
|
}
|
|
|
|
func splitCommaParam(raw string) []string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
out = append(out, trimmed)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isIssueActorType(s string) bool {
|
|
return s == "member" || s == "agent" || s == "squad"
|
|
}
|
|
|
|
func parseUUIDParamList(w http.ResponseWriter, raw, fieldName string) ([]pgtype.UUID, bool) {
|
|
parts := splitCommaParam(raw)
|
|
if len(parts) == 0 {
|
|
return nil, true
|
|
}
|
|
ids := make([]pgtype.UUID, 0, len(parts))
|
|
for _, part := range parts {
|
|
id, ok := parseUUIDOrBadRequest(w, part, fieldName)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, true
|
|
}
|
|
|
|
func parseActorFilterList(w http.ResponseWriter, raw, fieldName string) ([]issueActorFilter, bool) {
|
|
parts := splitCommaParam(raw)
|
|
if len(parts) == 0 {
|
|
return nil, true
|
|
}
|
|
filters := make([]issueActorFilter, 0, len(parts))
|
|
for _, part := range parts {
|
|
pieces := strings.SplitN(part, ":", 2)
|
|
if len(pieces) != 2 || !isIssueActorType(pieces[0]) || strings.TrimSpace(pieces[1]) == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid "+fieldName)
|
|
return nil, false
|
|
}
|
|
id, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(pieces[1]), fieldName)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
filters = append(filters, issueActorFilter{
|
|
actorType: pieces[0],
|
|
actorID: id,
|
|
})
|
|
}
|
|
return filters, true
|
|
}
|
|
|
|
func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
if h.DB == nil {
|
|
writeError(w, http.StatusInternalServerError, "database is unavailable")
|
|
return
|
|
}
|
|
|
|
groupBy := r.URL.Query().Get("group_by")
|
|
if groupBy == "" {
|
|
groupBy = "assignee"
|
|
}
|
|
if groupBy != "assignee" {
|
|
writeError(w, http.StatusBadRequest, "unsupported group_by")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
limit := 50
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v > 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
where := []string{"i.workspace_id = $1"}
|
|
args := []any{wsUUID}
|
|
addArg := func(v any) string {
|
|
args = append(args, v)
|
|
return "$" + strconv.Itoa(len(args))
|
|
}
|
|
|
|
statuses := splitCommaParam(r.URL.Query().Get("statuses"))
|
|
if len(statuses) == 0 {
|
|
statuses = splitCommaParam(r.URL.Query().Get("status"))
|
|
}
|
|
if len(statuses) > 0 {
|
|
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(statuses)))
|
|
}
|
|
|
|
priorities := splitCommaParam(r.URL.Query().Get("priorities"))
|
|
if len(priorities) == 0 {
|
|
priorities = splitCommaParam(r.URL.Query().Get("priority"))
|
|
}
|
|
if len(priorities) > 0 {
|
|
where = append(where, fmt.Sprintf("i.priority = ANY(%s::text[])", addArg(priorities)))
|
|
}
|
|
|
|
assigneeTypes := splitCommaParam(r.URL.Query().Get("assignee_types"))
|
|
if len(assigneeTypes) > 0 {
|
|
for _, assigneeType := range assigneeTypes {
|
|
if !isIssueActorType(assigneeType) {
|
|
writeError(w, http.StatusBadRequest, "invalid assignee_types")
|
|
return
|
|
}
|
|
}
|
|
where = append(where, fmt.Sprintf("i.assignee_type = ANY(%s::text[])", addArg(assigneeTypes)))
|
|
}
|
|
|
|
if raw := r.URL.Query().Get("assignee_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(id)))
|
|
}
|
|
if raw := r.URL.Query().Get("assignee_ids"); raw != "" {
|
|
ids, ok := parseUUIDParamList(w, raw, "assignee_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(ids) > 0 {
|
|
where = append(where, fmt.Sprintf("i.assignee_id = ANY(%s::uuid[])", addArg(ids)))
|
|
}
|
|
}
|
|
if raw := r.URL.Query().Get("creator_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "creator_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.creator_id = %s::uuid", addArg(id)))
|
|
}
|
|
if raw := r.URL.Query().Get("project_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(id)))
|
|
}
|
|
if filter, ok := parseMetadataFilterParam(w, r.URL.Query().Get("metadata")); !ok {
|
|
return
|
|
} else if filter != nil {
|
|
where = append(where, fmt.Sprintf("i.metadata @> %s::jsonb", addArg(string(filter))))
|
|
}
|
|
// Mirror the involves_user_id 4-branch UNION from sqlc's ListIssues /
|
|
// ListOpenIssues / CountIssues. ListGroupedIssues is a hand-written dynamic
|
|
// SQL builder that does not share parameters with sqlc, so the fragment is
|
|
// re-implemented here in lock-step. Member-direct assignment is excluded by
|
|
// design: that semantics belongs to tab 1 (`assignee_id`), and tab 3 must
|
|
// stay disjoint from tab 1.
|
|
if raw := r.URL.Query().Get("involves_user_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "involves_user_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
ref := addArg(id)
|
|
where = append(where, fmt.Sprintf(`(
|
|
(i.assignee_type = 'agent' AND i.assignee_id IN (
|
|
SELECT a.id FROM agent a
|
|
WHERE a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'member'
|
|
AND sm.member_id = %[1]s::uuid
|
|
UNION
|
|
SELECT s.id
|
|
FROM squad s
|
|
JOIN agent a ON a.id = s.leader_id
|
|
WHERE s.workspace_id = $1
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
UNION
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
JOIN agent a ON a.id = sm.member_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'agent'
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
)`, ref))
|
|
}
|
|
|
|
assigneeFilters, ok := parseActorFilterList(w, r.URL.Query().Get("assignee_filters"), "assignee_filters")
|
|
if !ok {
|
|
return
|
|
}
|
|
includeNoAssignee := r.URL.Query().Get("include_no_assignee") == "true"
|
|
if len(assigneeFilters) > 0 || includeNoAssignee {
|
|
ors := make([]string, 0, len(assigneeFilters)+1)
|
|
for _, filter := range assigneeFilters {
|
|
ors = append(ors, fmt.Sprintf(
|
|
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
|
|
addArg(filter.actorType),
|
|
addArg(filter.actorID),
|
|
))
|
|
}
|
|
if includeNoAssignee {
|
|
ors = append(ors, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
creatorFilters, ok := parseActorFilterList(w, r.URL.Query().Get("creator_filters"), "creator_filters")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(creatorFilters) > 0 {
|
|
ors := make([]string, 0, len(creatorFilters))
|
|
for _, filter := range creatorFilters {
|
|
ors = append(ors, fmt.Sprintf(
|
|
"(i.creator_type = %s::text AND i.creator_id = %s::uuid)",
|
|
addArg(filter.actorType),
|
|
addArg(filter.actorID),
|
|
))
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
projectIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("project_ids"), "project_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
includeNoProject := r.URL.Query().Get("include_no_project") == "true"
|
|
if len(projectIDs) > 0 || includeNoProject {
|
|
ors := make([]string, 0, 2)
|
|
if len(projectIDs) > 0 {
|
|
ors = append(ors, fmt.Sprintf("i.project_id = ANY(%s::uuid[])", addArg(projectIDs)))
|
|
}
|
|
if includeNoProject {
|
|
ors = append(ors, "i.project_id IS NULL")
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
labelIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("label_ids"), "label_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(labelIDs) > 0 {
|
|
where = append(where, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM issue_to_label itl WHERE itl.issue_id = i.id AND itl.label_id = ANY(%s::uuid[]))",
|
|
addArg(labelIDs),
|
|
))
|
|
}
|
|
|
|
if groupAssigneeType := r.URL.Query().Get("group_assignee_type"); groupAssigneeType != "" {
|
|
if groupAssigneeType == "none" {
|
|
where = append(where, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
|
|
} else {
|
|
if !isIssueActorType(groupAssigneeType) {
|
|
writeError(w, http.StatusBadRequest, "invalid group_assignee_type")
|
|
return
|
|
}
|
|
rawID := r.URL.Query().Get("group_assignee_id")
|
|
if rawID == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid group_assignee_id")
|
|
return
|
|
}
|
|
assigneeID, ok := parseUUIDOrBadRequest(w, rawID, "group_assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf(
|
|
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
|
|
addArg(groupAssigneeType),
|
|
addArg(assigneeID),
|
|
))
|
|
}
|
|
}
|
|
|
|
sortCol := "position"
|
|
if s := r.URL.Query().Get("sort"); s != "" {
|
|
switch s {
|
|
case "position", "title", "created_at", "start_date", "due_date":
|
|
sortCol = s
|
|
case "priority":
|
|
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid sort value")
|
|
return
|
|
}
|
|
}
|
|
sortDir := "ASC"
|
|
if sortCol != "position" {
|
|
if d := r.URL.Query().Get("direction"); d != "" {
|
|
switch strings.ToLower(d) {
|
|
case "asc":
|
|
sortDir = "ASC"
|
|
case "desc":
|
|
sortDir = "DESC"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid direction value")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
intraGroupOrder := sortCol
|
|
if !strings.HasPrefix(sortCol, "CASE") {
|
|
intraGroupOrder = "i." + sortCol
|
|
}
|
|
intraGroupOrder += " " + sortDir
|
|
if sortCol == "start_date" || sortCol == "due_date" {
|
|
intraGroupOrder += " NULLS LAST"
|
|
}
|
|
intraGroupOrder += ", i.created_at DESC"
|
|
|
|
offsetRef := addArg(int64(offset))
|
|
limitRef := addArg(int64(limit))
|
|
query := fmt.Sprintf(`
|
|
WITH ranked AS (
|
|
SELECT
|
|
i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
|
|
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
|
|
i.parent_issue_id, i.position, i.due_date, i.created_at, i.updated_at,
|
|
i.number, i.project_id, i.metadata,
|
|
COUNT(*) OVER (PARTITION BY i.assignee_type, i.assignee_id) AS group_total,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY i.assignee_type, i.assignee_id
|
|
ORDER BY %s
|
|
) AS rn
|
|
FROM issue i
|
|
WHERE %s
|
|
)
|
|
SELECT
|
|
id, workspace_id, title, description, status, priority,
|
|
assignee_type, assignee_id, creator_type, creator_id,
|
|
parent_issue_id, position, due_date, created_at, updated_at,
|
|
number, project_id, metadata, group_total
|
|
FROM ranked
|
|
WHERE rn > %s AND rn <= %s + %s
|
|
ORDER BY
|
|
CASE assignee_type
|
|
WHEN 'member' THEN 0
|
|
WHEN 'agent' THEN 1
|
|
WHEN 'squad' THEN 2
|
|
ELSE 3
|
|
END,
|
|
assignee_type NULLS LAST,
|
|
assignee_id NULLS LAST,
|
|
rn`, intraGroupOrder, strings.Join(where, " AND "), offsetRef, offsetRef, limitRef)
|
|
|
|
rows, err := h.DB.Query(ctx, query, args...)
|
|
if err != nil {
|
|
slog.Warn("ListGroupedIssues query failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
groupedRows := []groupedIssueRow{}
|
|
for rows.Next() {
|
|
var row groupedIssueRow
|
|
if err := rows.Scan(
|
|
&row.ID,
|
|
&row.WorkspaceID,
|
|
&row.Title,
|
|
&row.Description,
|
|
&row.Status,
|
|
&row.Priority,
|
|
&row.AssigneeType,
|
|
&row.AssigneeID,
|
|
&row.CreatorType,
|
|
&row.CreatorID,
|
|
&row.ParentIssueID,
|
|
&row.Position,
|
|
&row.DueDate,
|
|
&row.CreatedAt,
|
|
&row.UpdatedAt,
|
|
&row.Number,
|
|
&row.ProjectID,
|
|
&row.Metadata,
|
|
&row.GroupTotal,
|
|
); err != nil {
|
|
slog.Warn("ListGroupedIssues scan failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
|
|
return
|
|
}
|
|
groupedRows = append(groupedRows, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("ListGroupedIssues rows failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
|
|
return
|
|
}
|
|
|
|
ids := make([]pgtype.UUID, len(groupedRows))
|
|
for i, row := range groupedRows {
|
|
ids[i] = row.ID
|
|
}
|
|
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
|
|
groups := []IssueAssigneeGroupResponse{}
|
|
groupIndex := map[string]int{}
|
|
for _, row := range groupedRows {
|
|
groupID := assigneeGroupID(row.AssigneeType, row.AssigneeID)
|
|
idx, exists := groupIndex[groupID]
|
|
if !exists {
|
|
idx = len(groups)
|
|
groupIndex[groupID] = idx
|
|
groups = append(groups, IssueAssigneeGroupResponse{
|
|
ID: groupID,
|
|
AssigneeType: textToPtr(row.AssigneeType),
|
|
AssigneeID: uuidToPtr(row.AssigneeID),
|
|
Issues: []IssueResponse{},
|
|
Total: row.GroupTotal,
|
|
})
|
|
}
|
|
|
|
issue := issueListRowToResponse(row.ListIssuesRow, prefix)
|
|
labels := labelsMap[issue.ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
issue.Labels = &labels
|
|
groups[idx].Issues = append(groups[idx].Issues, issue)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, GroupedIssuesResponse{Groups: groups})
|
|
}
|
|
|
|
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := issueToResponse(issue, prefix)
|
|
detailLabels := h.labelsByIssue(r.Context(), issue.WorkspaceID, []pgtype.UUID{issue.ID})[uuidToString(issue.ID)]
|
|
if detailLabels == nil {
|
|
detailLabels = []LabelResponse{}
|
|
}
|
|
resp.Labels = &detailLabels
|
|
|
|
// Fetch issue reactions.
|
|
reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID)
|
|
if err == nil && len(reactions) > 0 {
|
|
resp.Reactions = make([]IssueReactionResponse, len(reactions))
|
|
for i, rx := range reactions {
|
|
resp.Reactions[i] = issueReactionToResponse(rx)
|
|
}
|
|
}
|
|
|
|
// Fetch issue-level attachments.
|
|
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err == nil && len(attachments) > 0 {
|
|
resp.Attachments = make([]AttachmentResponse, len(attachments))
|
|
for i, a := range attachments {
|
|
resp.Attachments[i] = h.attachmentToResponse(a)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
children, err := h.Queries.ListChildIssues(r.Context(), issue.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list child issues")
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := make([]IssueResponse, len(children))
|
|
for i, child := range children {
|
|
resp[i] = issueToResponse(child, prefix)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
})
|
|
}
|
|
|
|
// Cap on the number of parents we'll fan-out children for in one request.
|
|
// Swimlane's visible-lane count is naturally bounded by what fits on screen
|
|
// (typically <= 50), but cap explicitly so a malicious caller can't ANY()
|
|
// across the whole workspace's issue set in a single round trip.
|
|
const listChildrenByParentsLimit = 200
|
|
|
|
// ListChildrenByParents returns the union of children for the
|
|
// provided parent ids. Replaces the N-call fan-out Swimlane would otherwise
|
|
// have to make on mount (one /issues/:id/children per visible parent lane).
|
|
//
|
|
// Workspace scope is enforced at the query level — any parent_id that doesn't
|
|
// belong to the caller's workspace simply yields zero children, so callers
|
|
// can't probe parents across workspace boundaries.
|
|
func (h *Handler) ListChildrenByParents(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
raw := r.URL.Query().Get("parent_ids")
|
|
if raw == "" {
|
|
// Empty input is a no-op response (not an error) — simplifies the
|
|
// client which calls this unconditionally on Swimlane mount even
|
|
// when there are zero visible parent lanes.
|
|
writeJSON(w, http.StatusOK, map[string]any{"issues": []IssueResponse{}})
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
if len(parts) > listChildrenByParentsLimit {
|
|
writeError(w, http.StatusBadRequest, "too many parent_ids")
|
|
return
|
|
}
|
|
parentIDs := make([]pgtype.UUID, 0, len(parts))
|
|
for _, s := range parts {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
id, ok := parseUUIDOrBadRequest(w, s, "parent_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
parentIDs = append(parentIDs, id)
|
|
}
|
|
if len(parentIDs) == 0 {
|
|
writeJSON(w, http.StatusOK, map[string]any{"issues": []IssueResponse{}})
|
|
return
|
|
}
|
|
|
|
children, err := h.Queries.ListChildrenByParents(r.Context(), db.ListChildrenByParentsParams{
|
|
WorkspaceID: wsUUID,
|
|
ParentIds: parentIDs,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list child issues")
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), wsUUID)
|
|
resp := make([]IssueResponse, len(children))
|
|
for i, child := range children {
|
|
resp[i] = issueToResponse(child, prefix)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
|
|
wsID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := h.Queries.ChildIssueProgress(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get child issue progress")
|
|
return
|
|
}
|
|
|
|
type progressEntry struct {
|
|
ParentIssueID string `json:"parent_issue_id"`
|
|
Total int64 `json:"total"`
|
|
Done int64 `json:"done"`
|
|
}
|
|
resp := make([]progressEntry, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = progressEntry{
|
|
ParentIssueID: uuidToString(row.ParentIssueID),
|
|
Total: row.Total,
|
|
Done: row.Done,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"progress": resp,
|
|
})
|
|
}
|
|
|
|
// QuickCreateIssueRequest is the body for POST /api/issues/quick-create. The
|
|
// user picks an actor (agent or squad) in the modal and types one line of
|
|
// natural language; the server validates the actor's reachability up front,
|
|
// queues a quick-create task, and returns 202 immediately. The agent
|
|
// translates the prompt into a `multica issue create` invocation in the
|
|
// background; success and failure both surface as inbox notifications to
|
|
// the requester.
|
|
//
|
|
// Exactly one of AgentID / SquadID is required. When SquadID is set, the
|
|
// task is enqueued against the squad's leader agent and the leader receives
|
|
// the same Operating Protocol briefing it would for an issue assigned to
|
|
// the squad, so it can choose to delegate to a squad member as usual.
|
|
//
|
|
// ProjectID is optional and lets the modal target a specific project so
|
|
// the agent's `multica issue create` invocation passes `--project <uuid>`
|
|
// instead of letting it default. The frontend remembers the user's last
|
|
// pick per workspace, so frequent users skip retyping "in project X".
|
|
//
|
|
// ParentIssueID is optional and is set by the "Add sub issue" entry point
|
|
// when the modal is opened from an existing issue. The agent passes it
|
|
// through as `--parent <uuid>` so the new issue is filed as a sub-issue,
|
|
// keeping the sub-issue intent of the entry point regardless of whether
|
|
// the user submits via manual or agent mode.
|
|
type QuickCreateIssueRequest struct {
|
|
AgentID string `json:"agent_id,omitempty"`
|
|
SquadID string `json:"squad_id,omitempty"`
|
|
Prompt string `json:"prompt"`
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
ParentIssueID string `json:"parent_issue_id,omitempty"`
|
|
}
|
|
|
|
// QuickCreateIssueResponse echoes the queued task id so the frontend can
|
|
// correlate the eventual inbox item, even though completion is fully async.
|
|
type QuickCreateIssueResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
|
|
func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
|
var req QuickCreateIssueRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
prompt := strings.TrimSpace(req.Prompt)
|
|
if prompt == "" {
|
|
writeError(w, http.StatusBadRequest, "prompt is required")
|
|
return
|
|
}
|
|
|
|
hasAgent := strings.TrimSpace(req.AgentID) != ""
|
|
hasSquad := strings.TrimSpace(req.SquadID) != ""
|
|
if hasAgent == hasSquad {
|
|
writeError(w, http.StatusBadRequest, "exactly one of agent_id or squad_id is required")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
requesterID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
requesterUUID, ok := parseUUIDOrBadRequest(w, requesterID, "requester_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Resolve the actor to the agent that will actually run the task. For
|
|
// agent picks that's the agent itself; for squad picks it's the squad's
|
|
// leader agent. The leader receives a squad-leader briefing on dispatch
|
|
// (see daemon.go), matching the behavior of an issue assigned to the
|
|
// squad — picking a squad here is functionally "ask the squad leader to
|
|
// create this issue, on behalf of the squad".
|
|
var agentUUID pgtype.UUID
|
|
var squadUUID pgtype.UUID
|
|
if hasSquad {
|
|
var ok bool
|
|
squadUUID, ok = parseUUIDOrBadRequest(w, req.SquadID, "squad_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
|
ID: squadUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "squad not found")
|
|
return
|
|
}
|
|
if squad.ArchivedAt.Valid {
|
|
writeError(w, http.StatusBadRequest, "squad is archived")
|
|
return
|
|
}
|
|
agentUUID = squad.LeaderID
|
|
} else {
|
|
var ok bool
|
|
agentUUID, ok = parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reuse the same workspace-membership / archived / private-agent
|
|
// ownership rules as `validateAssigneePair` so a user can't POST a
|
|
// private agent_id they shouldn't be able to dispatch (the frontend
|
|
// filters them out, but the handler is the trust boundary). Squad
|
|
// picks reach this with the resolved leader agent; the same rules
|
|
// apply — a private leader behind a squad the user can't reach
|
|
// should still be rejected.
|
|
if status, msg := h.validateAssigneePair(
|
|
r.Context(), r, workspaceID,
|
|
pgtype.Text{String: "agent", Valid: true},
|
|
agentUUID,
|
|
); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
|
|
// Re-load the agent for the runtime liveness check below. Safe by
|
|
// construction: validateAssigneePair just confirmed it exists in this
|
|
// workspace and the caller has visibility.
|
|
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: agentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return
|
|
}
|
|
if !agent.RuntimeID.Valid {
|
|
writeAgentUnavailable(w, "agent has no runtime")
|
|
return
|
|
}
|
|
if !h.isRuntimeOnline(r.Context(), agent.RuntimeID) {
|
|
writeAgentUnavailable(w, "agent's runtime is offline")
|
|
return
|
|
}
|
|
|
|
// Daemon CLI version gate. The agent-side prompt + create-flow rely on
|
|
// behaviors introduced in MinQuickCreateCLIVersion (URL attachment
|
|
// handling, no-retry on partial failure). Older daemons either
|
|
// double-create issues on partial CLI failures or mishandle pasted
|
|
// screenshot URLs; fail closed before enqueuing rather than surface
|
|
// the breakage as an inbox failure twenty seconds later. Dev-built
|
|
// daemons (git-describe shape) are exempted inside CheckMinCLIVersion
|
|
// so `make daemon` works without weakening staging or production.
|
|
if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), agent.RuntimeID); status != 0 {
|
|
writeJSON(w, status, payload)
|
|
return
|
|
}
|
|
|
|
// Optional project_id — validate it belongs to the same workspace before
|
|
// pinning the task to it. The handler is the trust boundary; the frontend
|
|
// already only shows projects from the active workspace, but we re-check
|
|
// here so a forged request can't smuggle a foreign project ID through.
|
|
var projectUUID pgtype.UUID
|
|
if strings.TrimSpace(req.ProjectID) != "" {
|
|
pid, ok := parseUUIDOrBadRequest(w, req.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: pid,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "project not found")
|
|
return
|
|
}
|
|
projectUUID = pid
|
|
}
|
|
|
|
// Optional parent_issue_id — validate same-workspace membership just like
|
|
// the regular CreateIssue path. Frontend seeds this from the "Add sub
|
|
// issue" entry, but the handler re-checks so a forged request can't
|
|
// smuggle a foreign parent UUID through.
|
|
var parentIssueUUID pgtype.UUID
|
|
if strings.TrimSpace(req.ParentIssueID) != "" {
|
|
pid, ok := parseUUIDOrBadRequest(w, req.ParentIssueID, "parent_issue_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: pid,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil || !parent.ID.Valid {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
parentIssueUUID = pid
|
|
}
|
|
|
|
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, squadUUID, prompt, projectUUID, parentIssueUUID)
|
|
if err != nil {
|
|
slog.Warn("quick-create enqueue failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to enqueue quick-create task")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, QuickCreateIssueResponse{TaskID: uuidToString(task.ID)})
|
|
}
|
|
|
|
// writeAgentUnavailable returns 422 with a stable error code so the modal
|
|
// can show a "switch agent" hint without parsing the human-readable reason.
|
|
func writeAgentUnavailable(w http.ResponseWriter, reason string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"code": "agent_unavailable",
|
|
"reason": reason,
|
|
})
|
|
}
|
|
|
|
// isRuntimeOnline returns true when the given runtime is currently
|
|
// reachable (status == "online"). Quick-create rejects submissions whose
|
|
// agent's runtime is offline so the user gets immediate feedback in the
|
|
// modal instead of an inbox failure twenty seconds later.
|
|
func (h *Handler) isRuntimeOnline(ctx context.Context, runtimeID pgtype.UUID) bool {
|
|
rt, err := h.Queries.GetAgentRuntime(ctx, runtimeID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rt.Status == "online"
|
|
}
|
|
|
|
// checkQuickCreateDaemonVersion enforces MinQuickCreateCLIVersion against the
|
|
// CLI version the daemon reported at registration time (stored on the runtime
|
|
// row's metadata.cli_version). Returns (0, nil) when the version is
|
|
// acceptable, otherwise (status, payload) ready to hand to writeJSON.
|
|
//
|
|
// Failure shape is stable so the modal can branch on the `code` field and
|
|
// surface a "needs upgrade" hint that points at the specific runtime:
|
|
//
|
|
// 422 {
|
|
// "code": "daemon_version_unsupported",
|
|
// "current_version": "0.2.18" | "",
|
|
// "min_version": "0.2.20",
|
|
// "runtime_id": "<uuid>"
|
|
// }
|
|
func (h *Handler) checkQuickCreateDaemonVersion(ctx context.Context, runtimeID pgtype.UUID) (int, map[string]any) {
|
|
rt, err := h.Queries.GetAgentRuntime(ctx, runtimeID)
|
|
if err != nil {
|
|
// Runtime row vanished between the online check and here — treat
|
|
// as unavailable rather than wedging the request on a 500.
|
|
return http.StatusUnprocessableEntity, map[string]any{
|
|
"code": "agent_unavailable",
|
|
"reason": "agent's runtime is no longer registered",
|
|
}
|
|
}
|
|
current := readRuntimeCLIVersion(rt.Metadata)
|
|
switch err := agent.CheckMinCLIVersion(current); {
|
|
case err == nil:
|
|
return 0, nil
|
|
case errors.Is(err, agent.ErrCLIVersionMissing), errors.Is(err, agent.ErrCLIVersionTooOld):
|
|
return http.StatusUnprocessableEntity, map[string]any{
|
|
"code": "daemon_version_unsupported",
|
|
"current_version": current,
|
|
"min_version": agent.MinQuickCreateCLIVersion,
|
|
"runtime_id": uuidToString(runtimeID),
|
|
}
|
|
default:
|
|
// Defensive fall-through: unknown error from the version check is
|
|
// also fail-closed, since the gate exists precisely because we
|
|
// can't trust older daemons with this flow.
|
|
return http.StatusUnprocessableEntity, map[string]any{
|
|
"code": "daemon_version_unsupported",
|
|
"current_version": current,
|
|
"min_version": agent.MinQuickCreateCLIVersion,
|
|
"runtime_id": uuidToString(runtimeID),
|
|
}
|
|
}
|
|
}
|
|
|
|
// readRuntimeCLIVersion pulls metadata.cli_version off a runtime row. The
|
|
// metadata column is JSONB on the wire; the daemon stores the multica CLI
|
|
// version under that key during registration (see DaemonRegister).
|
|
func readRuntimeCLIVersion(metadata []byte) string {
|
|
if len(metadata) == 0 {
|
|
return ""
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(metadata, &m); err != nil {
|
|
return ""
|
|
}
|
|
if v, ok := m["cli_version"].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type CreateIssueRequest struct {
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Status string `json:"status"`
|
|
Priority string `json:"priority"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
ParentIssueID *string `json:"parent_issue_id"`
|
|
ProjectID *string `json:"project_id"`
|
|
StartDate *string `json:"start_date"`
|
|
DueDate *string `json:"due_date"`
|
|
AttachmentIDs []string `json:"attachment_ids,omitempty"`
|
|
// OriginType / OriginID stamp the new issue with its provenance so
|
|
// platform-internal flows can deterministically locate it later. Only
|
|
// trusted callers should set these — currently the daemon CLI passes
|
|
// them through for quick-create tasks (origin_type=quick_create,
|
|
// origin_id=agent_task_queue.id).
|
|
OriginType *string `json:"origin_type,omitempty"`
|
|
OriginID *string `json:"origin_id,omitempty"`
|
|
|
|
AllowDuplicate bool `json:"allow_duplicate,omitempty"`
|
|
}
|
|
|
|
func duplicateIssueMessage(issue IssueResponse) string {
|
|
return issueguard.DuplicateMessage(issue.Identifier, issue.Title, issue.Status)
|
|
}
|
|
|
|
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateIssueRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "title is required")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Get creator from context (set by auth middleware)
|
|
creatorID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
status := req.Status
|
|
if status == "" {
|
|
status = "todo"
|
|
}
|
|
priority := req.Priority
|
|
if priority == "" {
|
|
priority = "none"
|
|
}
|
|
|
|
var assigneeType pgtype.Text
|
|
var assigneeID pgtype.UUID
|
|
if req.AssigneeType != nil {
|
|
assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
|
}
|
|
if req.AssigneeID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
assigneeID = id
|
|
}
|
|
|
|
if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, assigneeType, assigneeID); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
|
|
var parentIssueID pgtype.UUID
|
|
var projectID pgtype.UUID
|
|
if req.ProjectID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID = id
|
|
}
|
|
if req.ParentIssueID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
parentIssueID = id
|
|
}
|
|
// Cross-workspace parent / project existence is enforced inside
|
|
// IssueService.Create (atomically with the create), so every entry
|
|
// point — HTTP, Lark, future MCP — gets the same boundary check
|
|
// without duplicating the lookup here.
|
|
|
|
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var startDate pgtype.Date
|
|
if req.StartDate != nil && *req.StartDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.StartDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
startDate = d
|
|
}
|
|
|
|
var dueDate pgtype.Date
|
|
if req.DueDate != nil && *req.DueDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.DueDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
dueDate = d
|
|
}
|
|
|
|
// Determine creator identity: agent (via X-Agent-ID header) or member.
|
|
creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID)
|
|
|
|
// Optional origin stamping (quick-create / autopilot). Only the
|
|
// allowed origin types are accepted; anything else is rejected so a
|
|
// rogue caller can't mint arbitrary origin labels. Both fields must
|
|
// be provided together.
|
|
var originType pgtype.Text
|
|
var originID pgtype.UUID
|
|
if req.OriginType != nil || req.OriginID != nil {
|
|
if req.OriginType == nil || req.OriginID == nil {
|
|
writeError(w, http.StatusBadRequest, "origin_type and origin_id must be provided together")
|
|
return
|
|
}
|
|
switch *req.OriginType {
|
|
case "quick_create":
|
|
// Allowed — daemon CLI passes this through from a quick-create task.
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "unsupported origin_type")
|
|
return
|
|
}
|
|
oid, ok := parseUUIDOrBadRequest(w, *req.OriginID, "origin_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
originType = pgtype.Text{String: *req.OriginType, Valid: true}
|
|
originID = oid
|
|
}
|
|
|
|
// Prefix is workspace-level; pre-compute once so both the broadcast
|
|
// payload builder and the HTTP response share the same value.
|
|
prefix := h.getIssuePrefix(r.Context(), wsUUID)
|
|
|
|
// Analytics agent ID: assignee agent when the issue is being assigned
|
|
// to an agent, otherwise the creator agent for agent-authored issues.
|
|
// Resolved here (not in the service) because creator identity is HTTP-side.
|
|
analyticsAgentID := ""
|
|
if assigneeType.Valid && assigneeType.String == "agent" {
|
|
analyticsAgentID = uuidToString(assigneeID)
|
|
}
|
|
if creatorType == "agent" && analyticsAgentID == "" {
|
|
analyticsAgentID = actualCreatorID
|
|
}
|
|
|
|
buildAttachmentResponses := func(atts []db.Attachment) []AttachmentResponse {
|
|
if len(atts) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]AttachmentResponse, len(atts))
|
|
for i, a := range atts {
|
|
out[i] = h.attachmentToResponse(a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
res, err := h.IssueService.Create(r.Context(), service.IssueCreateParams{
|
|
WorkspaceID: wsUUID,
|
|
Title: req.Title,
|
|
Description: ptrToText(req.Description),
|
|
Status: status,
|
|
Priority: priority,
|
|
AssigneeType: assigneeType,
|
|
AssigneeID: assigneeID,
|
|
CreatorType: creatorType,
|
|
CreatorID: parseUUID(actualCreatorID),
|
|
ParentIssueID: parentIssueID,
|
|
ProjectID: projectID,
|
|
StartDate: startDate,
|
|
DueDate: dueDate,
|
|
OriginType: originType,
|
|
OriginID: originID,
|
|
AttachmentIDs: attachmentIDs,
|
|
AllowDuplicate: req.AllowDuplicate,
|
|
}, service.IssueCreateOpts{
|
|
ActorID: actualCreatorID,
|
|
AnalyticsAgentID: analyticsAgentID,
|
|
Platform: func() string { p, _, _ := middleware.ClientMetadataFromContext(r.Context()); return p }(),
|
|
BroadcastPayload: func(issue db.Issue, atts []db.Attachment) map[string]any {
|
|
payload := issueToResponse(issue, prefix)
|
|
payload.Attachments = buildAttachmentResponses(atts)
|
|
return map[string]any{"issue": payload}
|
|
},
|
|
})
|
|
|
|
if errors.Is(err, service.ErrActiveDuplicate) {
|
|
dup := *res.DuplicateIssue
|
|
existing := issueToResponse(dup, h.getIssuePrefix(r.Context(), dup.WorkspaceID))
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"code": "active_duplicate_issue",
|
|
"error": duplicateIssueMessage(existing),
|
|
"issue": existing,
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrParentIssueNotFound) {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrProjectNotFound) {
|
|
writeError(w, http.StatusBadRequest, "project not found in this workspace")
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create issue: "+err.Error())
|
|
return
|
|
}
|
|
|
|
issue := res.Issue
|
|
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
|
|
|
|
resp := issueToResponse(issue, prefix)
|
|
resp.Attachments = buildAttachmentResponses(res.Attachments)
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
type UpdateIssueRequest struct {
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Status *string `json:"status"`
|
|
Priority *string `json:"priority"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Position *float64 `json:"position"`
|
|
StartDate *string `json:"start_date"`
|
|
DueDate *string `json:"due_date"`
|
|
ParentIssueID *string `json:"parent_issue_id"`
|
|
ProjectID *string `json:"project_id"`
|
|
// AttachmentIDs lets the description editor bind newly uploaded files to
|
|
// this issue so they surface in `GET /api/issues/:id/attachments` and the
|
|
// editor's preview Eye keeps working past a refresh. Existing bindings
|
|
// are idempotent — re-sending the same id is a no-op.
|
|
AttachmentIDs []string `json:"attachment_ids"`
|
|
}
|
|
|
|
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
prevIssue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID := requestUserID(r)
|
|
workspaceID := uuidToString(prevIssue.WorkspaceID)
|
|
|
|
// Read body as raw bytes so we can detect which fields were explicitly sent.
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
|
|
var req UpdateIssueRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Track which fields were explicitly present in JSON (even if null)
|
|
var rawFields map[string]json.RawMessage
|
|
json.Unmarshal(bodyBytes, &rawFields)
|
|
|
|
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
|
params := db.UpdateIssueParams{
|
|
ID: prevIssue.ID,
|
|
AssigneeType: prevIssue.AssigneeType,
|
|
AssigneeID: prevIssue.AssigneeID,
|
|
StartDate: prevIssue.StartDate,
|
|
DueDate: prevIssue.DueDate,
|
|
ParentIssueID: prevIssue.ParentIssueID,
|
|
ProjectID: prevIssue.ProjectID,
|
|
}
|
|
|
|
// COALESCE fields — only set when explicitly provided
|
|
if req.Title != nil {
|
|
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
|
}
|
|
if req.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
|
}
|
|
if req.Status != nil {
|
|
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
|
}
|
|
if req.Priority != nil {
|
|
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
|
|
}
|
|
if req.Position != nil {
|
|
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
|
|
}
|
|
// Nullable fields — only override when explicitly present in JSON
|
|
if _, ok := rawFields["assignee_type"]; ok {
|
|
if req.AssigneeType != nil {
|
|
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
|
} else {
|
|
params.AssigneeType = pgtype.Text{Valid: false} // explicit null = unassign
|
|
}
|
|
}
|
|
if _, ok := rawFields["assignee_id"]; ok {
|
|
if req.AssigneeID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
params.AssigneeID = id
|
|
} else {
|
|
params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign
|
|
}
|
|
}
|
|
if _, ok := rawFields["start_date"]; ok {
|
|
if req.StartDate != nil && *req.StartDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.StartDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
params.StartDate = d
|
|
} else {
|
|
params.StartDate = pgtype.Date{Valid: false} // explicit null = clear date
|
|
}
|
|
}
|
|
if _, ok := rawFields["due_date"]; ok {
|
|
if req.DueDate != nil && *req.DueDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.DueDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
params.DueDate = d
|
|
} else {
|
|
params.DueDate = pgtype.Date{Valid: false} // explicit null = clear date
|
|
}
|
|
}
|
|
if _, ok := rawFields["parent_issue_id"]; ok {
|
|
if req.ParentIssueID != nil {
|
|
newParentID, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
// Cannot set self as parent. Compare against prevIssue.ID (the
|
|
// resolved entity), not the raw URL string — `id` may be an
|
|
// identifier like "MUL-7".
|
|
if newParentID == prevIssue.ID {
|
|
writeError(w, http.StatusBadRequest, "an issue cannot be its own parent")
|
|
return
|
|
}
|
|
// Validate parent exists in the same workspace.
|
|
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: newParentID,
|
|
WorkspaceID: prevIssue.WorkspaceID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
|
cursor := newParentID
|
|
for depth := 0; depth < 10; depth++ {
|
|
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
|
if err != nil || !ancestor.ParentIssueID.Valid {
|
|
break
|
|
}
|
|
if ancestor.ParentIssueID == prevIssue.ID {
|
|
writeError(w, http.StatusBadRequest, "circular parent relationship detected")
|
|
return
|
|
}
|
|
cursor = ancestor.ParentIssueID
|
|
}
|
|
params.ParentIssueID = newParentID
|
|
} else {
|
|
params.ParentIssueID = pgtype.UUID{Valid: false} // explicit null = remove parent
|
|
}
|
|
}
|
|
if _, ok := rawFields["project_id"]; ok {
|
|
if req.ProjectID != nil {
|
|
projectUUID, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
params.ProjectID = projectUUID
|
|
} else {
|
|
params.ProjectID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
|
|
// Validate the resulting (assignee_type, assignee_id) pair when the caller
|
|
// touches either field. Existing data on the issue is left alone if the
|
|
// caller is not changing it.
|
|
_, touchedType := rawFields["assignee_type"]
|
|
_, touchedID := rawFields["assignee_id"]
|
|
if touchedType || touchedID {
|
|
if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, params.AssigneeType, params.AssigneeID); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
}
|
|
|
|
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
|
if err != nil {
|
|
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update issue: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if len(attachmentIDs) > 0 {
|
|
h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, attachmentIDs)
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := issueToResponse(issue, prefix)
|
|
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)
|
|
|
|
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&
|
|
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
|
statusChanged := req.Status != nil && prevIssue.Status != issue.Status
|
|
priorityChanged := req.Priority != nil && prevIssue.Priority != issue.Priority
|
|
descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
|
|
titleChanged := req.Title != nil && prevIssue.Title != issue.Title
|
|
prevStartDate := dateToPtr(prevIssue.StartDate)
|
|
startDateChanged := prevStartDate != resp.StartDate && (prevStartDate == nil) != (resp.StartDate == nil) ||
|
|
(prevStartDate != nil && resp.StartDate != nil && *prevStartDate != *resp.StartDate)
|
|
prevDueDate := dateToPtr(prevIssue.DueDate)
|
|
dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) ||
|
|
(prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate)
|
|
|
|
// Determine actor identity: agent (via X-Agent-ID header) or member.
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
|
|
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
|
|
"issue": resp,
|
|
"assignee_changed": assigneeChanged,
|
|
"status_changed": statusChanged,
|
|
"priority_changed": priorityChanged,
|
|
"start_date_changed": startDateChanged,
|
|
"due_date_changed": dueDateChanged,
|
|
"description_changed": descriptionChanged,
|
|
"title_changed": titleChanged,
|
|
"prev_title": prevIssue.Title,
|
|
"prev_assignee_type": textToPtr(prevIssue.AssigneeType),
|
|
"prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
|
|
"prev_status": prevIssue.Status,
|
|
"prev_priority": prevIssue.Priority,
|
|
"prev_start_date": prevStartDate,
|
|
"prev_due_date": prevDueDate,
|
|
"prev_description": textToPtr(prevIssue.Description),
|
|
"creator_type": prevIssue.CreatorType,
|
|
"creator_id": uuidToString(prevIssue.CreatorID),
|
|
})
|
|
|
|
// Reconcile task queue when assignee changes.
|
|
if assigneeChanged {
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
|
|
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
|
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
|
}
|
|
|
|
// Squad assign: trigger the squad leader, respecting the backlog
|
|
// parking-lot rule used by agent assignment.
|
|
if h.shouldEnqueueSquadLeaderOnAssign(r.Context(), issue) {
|
|
h.enqueueSquadLeaderTask(r.Context(), issue, pgtype.UUID{}, actorType, actorID)
|
|
}
|
|
}
|
|
|
|
// Trigger the assigned agent when an issue moves out of backlog. Backlog
|
|
// acts as a parking lot — moving to an active status signals the issue is
|
|
// ready for work. Agent actors are allowed here so the documented
|
|
// serial sub-task workflow works (parent agent finishes Step 1, then
|
|
// promotes Step 2 from backlog→todo, regardless of who Step 2 is
|
|
// assigned to). The only excluded case is the real self-loop: an agent
|
|
// promoting the same issue its current task is running on. Same-agent,
|
|
// cross-issue handoff (Agent A finishing one task and promoting another
|
|
// issue assigned to A) must still fire — that is the documented serial
|
|
// chain.
|
|
if statusChanged && !assigneeChanged &&
|
|
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" &&
|
|
!h.isAgentRunningOnIssue(r, actorType, issue) {
|
|
if h.isAgentAssigneeReady(r.Context(), issue) {
|
|
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
|
}
|
|
if h.isSquadLeaderReady(r.Context(), issue) {
|
|
h.enqueueSquadLeaderTask(r.Context(), issue, pgtype.UUID{}, actorType, actorID)
|
|
}
|
|
}
|
|
|
|
// Cancel active tasks when the issue is cancelled by a user.
|
|
// This is distinct from agent-managed status transitions — cancellation
|
|
// is a user-initiated terminal action that should stop execution.
|
|
if statusChanged && issue.Status == "cancelled" {
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
}
|
|
|
|
// Platform-driven parent notification: when this issue transitions into
|
|
// `done` and has a parent, post a top-level system comment on the parent
|
|
// (MUL-2538 — replaces the agent-prompt rule that caused self-mention
|
|
// loops in PR #2918). The helper guards on transition + parent state and
|
|
// fails best-effort.
|
|
if statusChanged {
|
|
h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// validateAssigneePair verifies the (assignee_type, assignee_id) pair refers
|
|
// to an existing entity in the workspace. For agent assignees it also rejects
|
|
// archived agents and runs the private-agent gate via canAccessPrivateAgent
|
|
// — assigning an issue is a task-producing surface, so it must use the same
|
|
// predicate as chat / @-mention / history. Agent callers (X-Agent-ID) bypass
|
|
// the gate so A2A flows can still hand work off to private agents.
|
|
//
|
|
// Returns (statusCode, errorMessage). statusCode == 0 means the pair is valid;
|
|
// callers should treat any non-zero status as a rejection and surface it back
|
|
// to the client.
|
|
func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, workspaceID string, assigneeType pgtype.Text, assigneeID pgtype.UUID) (int, string) {
|
|
// Both unset → unassigned issue, valid.
|
|
if !assigneeType.Valid && !assigneeID.Valid {
|
|
return 0, ""
|
|
}
|
|
// Exactly one of type/id provided → callers must always pair them.
|
|
if assigneeType.Valid != assigneeID.Valid {
|
|
return http.StatusBadRequest, "assignee_type and assignee_id must be provided together"
|
|
}
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
return http.StatusBadRequest, "invalid workspace_id"
|
|
}
|
|
switch assigneeType.String {
|
|
case "member":
|
|
if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: assigneeID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
return http.StatusBadRequest, "assignee_id does not refer to a member of this workspace"
|
|
}
|
|
return 0, ""
|
|
case "agent":
|
|
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
|
ID: assigneeID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
return http.StatusBadRequest, "assignee_id does not refer to an agent of this workspace"
|
|
}
|
|
if agent.ArchivedAt.Valid {
|
|
return http.StatusBadRequest, "cannot assign to archived agent"
|
|
}
|
|
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
|
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, workspaceID) {
|
|
return http.StatusForbidden, "cannot assign to private agent"
|
|
}
|
|
return 0, ""
|
|
case "squad":
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: assigneeID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
return http.StatusBadRequest, "assignee_id does not refer to a squad in this workspace"
|
|
}
|
|
if squad.ArchivedAt.Valid {
|
|
return http.StatusBadRequest, "cannot assign to an archived squad"
|
|
}
|
|
leader, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil || leader.ArchivedAt.Valid {
|
|
return http.StatusBadRequest, "squad leader is archived; cannot assign to this squad"
|
|
}
|
|
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
|
if !h.canAccessPrivateAgent(ctx, leader, actorType, actorID, workspaceID) {
|
|
return http.StatusForbidden, "cannot assign to squad with private leader"
|
|
}
|
|
return 0, ""
|
|
default:
|
|
return http.StatusBadRequest, "assignee_type must be 'member', 'agent', or 'squad'"
|
|
}
|
|
}
|
|
|
|
// shouldEnqueueAgentTask returns true when an issue creation or assignment
|
|
// should trigger the assigned agent. Backlog issues are skipped — backlog
|
|
// acts as a parking lot where issues can be pre-assigned without immediately
|
|
// triggering execution. Moving out of backlog is handled separately in
|
|
// UpdateIssue.
|
|
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
|
|
if issue.Status == "backlog" {
|
|
return false
|
|
}
|
|
return h.isAgentAssigneeReady(ctx, issue)
|
|
}
|
|
|
|
// shouldEnqueueOnComment returns true if a member comment on this issue should
|
|
// trigger the assigned agent. Fires for any status — comments are
|
|
// conversational and can happen at any stage, including after completion
|
|
// (e.g. follow-up questions on a done issue).
|
|
//
|
|
// Mirrors the private-agent gate that enqueueMentionedAgentTasks applies on the
|
|
// @mention path: once an owner/admin assigns a private agent to an issue, the
|
|
// agent's UUID is "welded" onto the issue and remains visible to every member
|
|
// who can view it. Without this check any of those members could dispatch a new
|
|
// task to the private agent simply by commenting (#3300).
|
|
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue, actorType, actorID string) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return false
|
|
}
|
|
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, uuidToString(issue.WorkspaceID)) {
|
|
return false
|
|
}
|
|
// Coalescing queue: allow enqueue when a task is running (so the agent
|
|
// picks up new comments on the next cycle) but skip if this agent already
|
|
// has a pending task (natural dedup for rapid-fire comments).
|
|
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issue.ID,
|
|
AgentID: issue.AssigneeID,
|
|
})
|
|
if err != nil || hasPending {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isAgentRunningOnIssue reports whether the calling agent's current task
|
|
// (identified by X-Task-ID) is running for the exact issue being promoted.
|
|
// That is the only true self-loop on backlog→active: the agent flipping
|
|
// the same issue its own task is executing for would immediately re-enqueue
|
|
// itself, complete the run, flip again, and so on.
|
|
//
|
|
// Same-agent cross-issue handoff (Agent A finishing a task on issue I1 then
|
|
// promoting issue I2 — even when I2 is also assigned to A) is NOT a loop
|
|
// and must fire; that is the documented serial sub-task chain. Member
|
|
// actors never match.
|
|
//
|
|
// X-Task-ID is guaranteed to be present and consistent when actorType is
|
|
// "agent": resolveActor demotes the actor to "member" otherwise (handler.go
|
|
// resolveActor). We still recheck defensively — a future caller could pass
|
|
// agent identity through a different path.
|
|
func (h *Handler) isAgentRunningOnIssue(r *http.Request, actorType string, issue db.Issue) bool {
|
|
if actorType != "agent" {
|
|
return false
|
|
}
|
|
taskIDStr := r.Header.Get("X-Task-ID")
|
|
if taskIDStr == "" {
|
|
return false
|
|
}
|
|
taskUUID, err := util.ParseUUID(taskIDStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !task.IssueID.Valid {
|
|
return false
|
|
}
|
|
return uuidToString(task.IssueID) == uuidToString(issue.ID)
|
|
}
|
|
|
|
// isAgentAssigneeReady checks if an issue is assigned to an active agent
|
|
// with a valid runtime.
|
|
func (h *Handler) isAgentAssigneeReady(ctx context.Context, issue db.Issue) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
|
|
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
// Fail any linked autopilot runs before delete (ON DELETE SET NULL clears issue_id).
|
|
h.Queries.FailAutopilotRunsByIssue(r.Context(), issue.ID)
|
|
|
|
// Collect all attachment URLs (issue-level + comment-level) before CASCADE delete.
|
|
attachmentURLs, _ := h.Queries.ListAttachmentURLsByIssueOrComments(r.Context(), issue.ID)
|
|
|
|
err := h.Queries.DeleteIssue(r.Context(), db.DeleteIssueParams{
|
|
ID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
|
return
|
|
}
|
|
|
|
h.deleteS3Objects(r.Context(), attachmentURLs)
|
|
userID := requestUserID(r)
|
|
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
|
// Always emit the resolved UUID — frontend caches key by UUID, so an
|
|
// identifier-style payload ("MUL-123") would leave stale entries on
|
|
// other clients after an identifier-path delete.
|
|
resolvedID := uuidToString(issue.ID)
|
|
h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": resolvedID})
|
|
slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", resolvedID, "workspace_id", uuidToString(issue.WorkspaceID))...)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Batch operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type BatchUpdateIssuesRequest struct {
|
|
IssueIDs []string `json:"issue_ids"`
|
|
Updates UpdateIssueRequest `json:"updates"`
|
|
}
|
|
|
|
func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
|
|
var req BatchUpdateIssuesRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if len(req.IssueIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "issue_ids is required")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Detect which fields in "updates" were explicitly set (including null).
|
|
var rawTop map[string]json.RawMessage
|
|
json.Unmarshal(bodyBytes, &rawTop)
|
|
var rawUpdates map[string]json.RawMessage
|
|
if raw, exists := rawTop["updates"]; exists {
|
|
json.Unmarshal(raw, &rawUpdates)
|
|
}
|
|
|
|
// Short-circuit when no mutation field is present in `updates`. Without
|
|
// this, the loop below runs N no-op UPDATEs (every if-guard skips, every
|
|
// COALESCE preserves the existing value) and reports `{"updated": N}` —
|
|
// the response cheerfully claims success while nothing changed. Most
|
|
// real-world cases that hit this path are caller mistakes (status placed
|
|
// at the top level, "update" misspelled as singular). Telling the truth
|
|
// here — `{"updated": 0}` — keeps the wire shape stable while making the
|
|
// count match reality. See multica-ai/multica#1660.
|
|
hasMutation := req.Updates.Title != nil ||
|
|
req.Updates.Description != nil ||
|
|
req.Updates.Status != nil ||
|
|
req.Updates.Priority != nil ||
|
|
req.Updates.Position != nil
|
|
if !hasMutation {
|
|
for _, k := range []string{"assignee_type", "assignee_id", "start_date", "due_date", "parent_issue_id", "project_id"} {
|
|
if _, ok := rawUpdates[k]; ok {
|
|
hasMutation = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasMutation {
|
|
writeJSON(w, http.StatusOK, map[string]any{"updated": 0})
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
updated := 0
|
|
for _, issueID := range req.IssueIDs {
|
|
issueUUID, err := util.ParseUUID(issueID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
prevIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: issueUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
params := db.UpdateIssueParams{
|
|
ID: prevIssue.ID,
|
|
AssigneeType: prevIssue.AssigneeType,
|
|
AssigneeID: prevIssue.AssigneeID,
|
|
StartDate: prevIssue.StartDate,
|
|
DueDate: prevIssue.DueDate,
|
|
ParentIssueID: prevIssue.ParentIssueID,
|
|
ProjectID: prevIssue.ProjectID,
|
|
}
|
|
|
|
if req.Updates.Title != nil {
|
|
params.Title = pgtype.Text{String: *req.Updates.Title, Valid: true}
|
|
}
|
|
if req.Updates.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Updates.Description, Valid: true}
|
|
}
|
|
if req.Updates.Status != nil {
|
|
params.Status = pgtype.Text{String: *req.Updates.Status, Valid: true}
|
|
}
|
|
if req.Updates.Priority != nil {
|
|
params.Priority = pgtype.Text{String: *req.Updates.Priority, Valid: true}
|
|
}
|
|
if req.Updates.Position != nil {
|
|
params.Position = pgtype.Float8{Float64: *req.Updates.Position, Valid: true}
|
|
}
|
|
if _, ok := rawUpdates["assignee_type"]; ok {
|
|
if req.Updates.AssigneeType != nil {
|
|
params.AssigneeType = pgtype.Text{String: *req.Updates.AssigneeType, Valid: true}
|
|
} else {
|
|
params.AssigneeType = pgtype.Text{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["assignee_id"]; ok {
|
|
if req.Updates.AssigneeID != nil {
|
|
assigneeUUID, err := util.ParseUUID(*req.Updates.AssigneeID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.AssigneeID = assigneeUUID
|
|
} else {
|
|
params.AssigneeID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["start_date"]; ok {
|
|
if req.Updates.StartDate != nil && *req.Updates.StartDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.Updates.StartDate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.StartDate = d
|
|
} else {
|
|
params.StartDate = pgtype.Date{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["due_date"]; ok {
|
|
if req.Updates.DueDate != nil && *req.Updates.DueDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.Updates.DueDate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.DueDate = d
|
|
} else {
|
|
params.DueDate = pgtype.Date{Valid: false}
|
|
}
|
|
}
|
|
|
|
if _, ok := rawUpdates["parent_issue_id"]; ok {
|
|
if req.Updates.ParentIssueID != nil {
|
|
newParentID, err := util.ParseUUID(*req.Updates.ParentIssueID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Cannot set self as parent.
|
|
if newParentID == prevIssue.ID {
|
|
continue
|
|
}
|
|
// Validate parent exists in the same workspace.
|
|
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: newParentID,
|
|
WorkspaceID: prevIssue.WorkspaceID,
|
|
}); err != nil {
|
|
continue
|
|
}
|
|
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
|
cycleDetected := false
|
|
cursor := newParentID
|
|
for depth := 0; depth < 10; depth++ {
|
|
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
|
if err != nil || !ancestor.ParentIssueID.Valid {
|
|
break
|
|
}
|
|
if ancestor.ParentIssueID == prevIssue.ID {
|
|
cycleDetected = true
|
|
break
|
|
}
|
|
cursor = ancestor.ParentIssueID
|
|
}
|
|
if cycleDetected {
|
|
continue
|
|
}
|
|
params.ParentIssueID = newParentID
|
|
} else {
|
|
params.ParentIssueID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["project_id"]; ok {
|
|
if req.Updates.ProjectID != nil {
|
|
projectUUID, err := util.ParseUUID(*req.Updates.ProjectID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.ProjectID = projectUUID
|
|
} else {
|
|
params.ProjectID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
|
|
// Validate the resulting assignee pair when this batch update touches
|
|
// either assignee field. Skip the issue silently on failure.
|
|
_, batchTouchedType := rawUpdates["assignee_type"]
|
|
_, batchTouchedID := rawUpdates["assignee_id"]
|
|
if batchTouchedType || batchTouchedID {
|
|
if status, _ := h.validateAssigneePair(r.Context(), r, workspaceID, params.AssigneeType, params.AssigneeID); status != 0 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
|
if err != nil {
|
|
slog.Warn("batch update issue failed", "issue_id", issueID, "error", err)
|
|
continue
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := issueToResponse(issue, prefix)
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
|
|
assigneeChanged := (req.Updates.AssigneeType != nil || req.Updates.AssigneeID != nil) &&
|
|
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
|
statusChanged := req.Updates.Status != nil && prevIssue.Status != issue.Status
|
|
priorityChanged := req.Updates.Priority != nil && prevIssue.Priority != issue.Priority
|
|
|
|
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
|
|
"issue": resp,
|
|
"assignee_changed": assigneeChanged,
|
|
"status_changed": statusChanged,
|
|
"priority_changed": priorityChanged,
|
|
})
|
|
|
|
if assigneeChanged {
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
|
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
|
}
|
|
if h.shouldEnqueueSquadLeaderOnAssign(r.Context(), issue) {
|
|
h.enqueueSquadLeaderTask(r.Context(), issue, pgtype.UUID{}, actorType, actorID)
|
|
}
|
|
}
|
|
|
|
// Trigger agent when moving out of backlog (batch). Mirrors the
|
|
// single-update path above — agent actors are allowed so serial
|
|
// sub-task chains work, and the same task-issue self-loop guard
|
|
// prevents an agent from re-triggering itself on the same issue.
|
|
if statusChanged && !assigneeChanged &&
|
|
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" &&
|
|
!h.isAgentRunningOnIssue(r, actorType, issue) {
|
|
if h.isAgentAssigneeReady(r.Context(), issue) {
|
|
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
|
}
|
|
if h.isSquadLeaderReady(r.Context(), issue) {
|
|
h.enqueueSquadLeaderTask(r.Context(), issue, pgtype.UUID{}, actorType, actorID)
|
|
}
|
|
}
|
|
|
|
// Cancel active tasks when the issue is cancelled by a user.
|
|
if statusChanged && issue.Status == "cancelled" {
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
}
|
|
|
|
// Platform-driven parent notification, mirrored from UpdateIssue
|
|
// (MUL-2538). Best-effort; failure does not abort the batch.
|
|
if statusChanged {
|
|
h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID)
|
|
}
|
|
|
|
updated++
|
|
}
|
|
|
|
slog.Info("batch update issues", append(logger.RequestAttrs(r), "count", updated)...)
|
|
writeJSON(w, http.StatusOK, map[string]any{"updated": updated})
|
|
}
|
|
|
|
type BatchDeleteIssuesRequest struct {
|
|
IssueIDs []string `json:"issue_ids"`
|
|
}
|
|
|
|
func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
|
|
var req BatchDeleteIssuesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if len(req.IssueIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "issue_ids is required")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
deleted := 0
|
|
for _, issueID := range req.IssueIDs {
|
|
issueUUID, err := util.ParseUUID(issueID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: issueUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
h.Queries.FailAutopilotRunsByIssue(r.Context(), issue.ID)
|
|
|
|
// Collect attachment URLs before CASCADE delete to clean up S3 objects.
|
|
attachmentURLs, _ := h.Queries.ListAttachmentURLsByIssueOrComments(r.Context(), issue.ID)
|
|
|
|
if err := h.Queries.DeleteIssue(r.Context(), db.DeleteIssueParams{
|
|
ID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
}); err != nil {
|
|
slog.Warn("batch delete issue failed", "issue_id", issueID, "error", err)
|
|
continue
|
|
}
|
|
|
|
h.deleteS3Objects(r.Context(), attachmentURLs)
|
|
|
|
// Always emit the resolved UUID — frontend caches key by UUID.
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": uuidToString(issue.ID)})
|
|
deleted++
|
|
}
|
|
|
|
slog.Info("batch delete issues", append(logger.RequestAttrs(r), "count", deleted)...)
|
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted})
|
|
}
|