Compare commits

..

12 Commits

Author SHA1 Message Date
Multica Eve
de7f3cb9e3 docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840) (#4706)
* docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840)

Lands the daily release notes for v0.3.32 in all four landing locales (en / zh-Hans / ko / ja). Groups today's PRs by Feature / Improvement / Bug Fix with product-oriented wording, keeping technical commit jargon out of the user-facing changelog.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): drop two fixes from v0.3.32 per release-confirmation feedback

Removes the 'deleted agents hidden from usage leaderboard' (MUL-3771, #4637) and 'Antigravity daemon-mode guidance' fix lines from all four locales, leaving five customer-facing fixes for the 2026-06-29 release. The Issue body on MUL-3840 is kept in sync separately.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): drop reverted self-host onboarding beacon from v0.3.32

The anonymous self-host onboarding source beacon (MUL-3708, #4691) was reverted in #4712 because of issues with the collection path. Remove the corresponding feature bullet from all four locales so the v0.3.32 changelog only advertises what actually ships.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): hold Slack BYO app feature back from v0.3.32 user changelog

Per release confirmation, the Slack bring-your-own-app feature is shipping behind a disabled frontend entry for v0.3.32 — code lands, but it is not publicly available yet. Drop the Slack feature bullet from all four locales and rewrite the entry title around what is actually exposed to users (Remove parent Issue + daemon reconnect + attachment preview improvements).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 19:18:13 +08:00
Naiyuan Qing
b336f07617 Revert "feat(analytics): anonymous self-host onboarding source beacon (MUL-37…" (#4712)
This reverts commit 63eb6f73ad.
2026-06-29 19:01:14 +08:00
Jiayuan Zhang
10b33b14f5 fix(dashboard): reconcile deleted-agent spend in usage leaderboard (MUL-3776) (#4661)
PR #4637 (MUL-3771) dropped hard-deleted agents from the per-agent
leaderboard so they'd stop rendering as a bare UUID, but the top-line
Cost/Tokens KPIs still count their spend (those totals aggregate
task_usage_hourly without joining `agent`). The breakdown therefore no
longer reconciled with the totals (#4640).

Instead of dropping unknown-agent rows, fold them into a single
aggregated "Deleted agents" row: sum(visible rows) == KPI total again,
with no UUID exposed. Archived agents still appear as themselves (the
agent list is fetched with include_archived). The bucket carries
tokens + cost only; Time/Tasks render as "—" since the run-time rollups
inner-join `agent` and never attribute time to deleted agents.

- bucketUnknownAgentRows replaces filterKnownAgentRows in dashboard/utils
- Leaderboard renders the sentinel bucket row with a neutral placeholder
  and a "{{count}} agents · {{deleted}} deleted" caption
- i18n: deleted_agents + caption_with_deleted (en/zh-Hans/ja/ko)
- tests cover bucket reconciliation, archived-stays, null-loading passthrough

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:21:35 +08:00
Bohan Jiang
9f1766cdb3 docs(slack): binding link uses the web app URL, not MULTICA_PUBLIC_URL (MUL-3666) (#4705)
Match the code fix (#4703): the "link your account" link is built from the web
app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), which a normal deployment already
sets — not MULTICA_PUBLIC_URL (the backend/API URL). Updates en + zh + ja + ko.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:18:18 +08:00
Bohan Jiang
2b940046d7 fix(slack): build the binding link from the web app URL, matching Lark (MUL-3666) (#4703)
The Slack "link your account" prompt built its redeem link from
MULTICA_PUBLIC_URL, but /slack/bind is a web-app page — the link must use the
web app URL, not the backend/API URL. MULTICA_PUBLIC_URL is intentionally the
backend/API public URL (webhooks, daemon server_url, attachments); the Lark
replier already uses appURLFromEnv() (MULTICA_APP_URL ?? FRONTEND_ORIGIN).
Slack was never migrated, so on deployments that set FRONTEND_ORIGIN but not
MULTICA_PUBLIC_URL (e.g. dev) the binding prompt silently failed
("public url not configured") and @-mentions got no response.

Rename slack.OutboundReplierConfig.PublicURL -> AppURL and feed it
appURLFromEnv() in router.go, mirroring Lark. Backend/API-URL uses of
MULTICA_PUBLIC_URL (webhooks, attachments, daemon server_url) are unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:13:41 +08:00
Multica Eve
f59cb2f494 MUL-3834: harden daemon websocket reconnect (#4699)
* MUL-3834 harden daemon websocket reconnect

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3834 stabilize daemon websocket liveness tests

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 16:46:57 +08:00
Bohan Jiang
d2bc85e01a refactor(slack): declutter the Slack connect UI (#4700)
* refactor(slack): declutter the Slack connect UI

Trim the Slack bring-your-own-app UI to match the leaner Lark card and
stop burying the setup behind prose nobody reads:

- Drop the "Required bot scopes: …" block from the connect dialog.
- Shorten the Slack integration card description to mirror the Lark
  card; the token/admin details stay in the setup docs.
- Remove the dialog intro paragraph and the per-field token hints;
  replace the small "Read the setup guide" link with a larger,
  more prominent step-by-step guide link.

Removes the now-unused i18n keys (byo_dialog_intro, byo_bot_token_hint,
byo_app_token_hint, byo_scopes_hint) across en/zh-Hans/ja/ko.

* docs(slack): drop the users:read warning callout

The bot manifest already lists users:read as a required scope (with the
bots.info rationale in the scopes table), so the standalone warning
callout was redundant. Removed across en/zh/ja/ko.
2026-06-29 16:31:42 +08:00
Naiyuan Qing
63eb6f73ad feat(analytics): anonymous self-host onboarding source beacon (MUL-3708) (#4691)
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)

Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.

- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
  non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
  not the backend URL, which official often leaves unset), per-instance
  salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
  rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
  rejection. Lands in PostHog as self_host_source_channel with a
  deterministic uuid (best-effort dedup), $process_person_profile=false,
  and deployment=self_host — a distinct event name so it never pollutes the
  official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
  blocks onboarding. Only channel enum(s) + two per-instance hashes leave
  the box — never user_id/email/name/workspace/org/domain/role/use_case/the
  source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
  gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
  SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
  how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
  team_size→source drift in docs/analytics.md.

Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)

Addresses Howard CI blockers on #4691 (no product-direction change):

- loadInstanceSalt returns "" on nil pool; salt is only loaded when
  ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
  "router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
  IncForEvent case, so every analytics event is paired with a Prometheus
  counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
  helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
  IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.

Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:56:16 +08:00
Ryan Yu
c2e8892194 fix(chat): refresh message caches on reconnect (MUL-3831) (#4677)
Invalidate per-session chat message caches (messages, messages-page,
pending-task, task-messages) on websocket reconnect / WS instance change so
a chat that missed chat/task events while disconnected recovers without a
full reload, matching the existing per-issue recovery pattern.

Co-authored-by: Ryan <1141524679@qq.com>
2026-06-29 15:34:36 +08:00
Bohan Jiang
5206d7c613 feat(slack): link the Slack integration guide from the Connect dialog (MUL-3666) (#4697)
The bring-your-own-app Connect Slack dialog only had a (hidden) video CTA, so
users had no in-product pointer to the setup instructions. Add an always-visible
"Read the setup guide" link that opens the Slack integration docs page,
localized to the viewer's language (https://multica.ai/docs[/<lang>]/slack-bot-integration),
following the existing doc-link convention in the app. Adds the byo_docs_link
string to en / zh-Hans / ja / ko.

The doc page it points to ships in the docs PR (#4693).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:33:40 +08:00
Bohan Jiang
e444698a09 docs: channel integrations + Slack bot + Slack app setup (MUL-3666) (#4693)
* docs: channel integrations overview + Slack bot page + Slack app setup guide (MUL-3666)

- channels.mdx: channel-engine overview with an architecture diagram (Mermaid),
  the inbound pipeline, the session/context model, and the authorization gates
  (account binding + workspace membership) — all shared by Lark and Slack.
- slack-bot-integration.mdx: the Slack channel page (mirrors lark-bot-integration)
  — BYO connect flow, usage (@ in channel / DM / /issue), one-bot-per-agent,
  permissions, and self-host (MULTICA_SLACK_SECRET_KEY).
- create-slack-app.mdx: standalone step-by-step — create a Slack app from a
  copy-paste manifest, install it, and grab the bot + app-level tokens.
- meta.json: list the three pages under Integrations.

English (canonical) only this pass; zh/ja/ko localization to follow.

Co-authored-by: multica-agent <github@multica.ai>

* docs(slack): inline full manifest + step-by-step setup into the Slack page (MUL-3666)

The Slack page only linked out for setup, which read as too thin. Fold the
complete, code-verified app manifest and the full walkthrough (create from
manifest → install + bot token → app-level token with connections:write →
connect in Multica) directly into slack-bot-integration.mdx, plus a table
explaining what each scope/event is for.

Remove the now-redundant standalone create-slack-app.mdx (its content lives on
the Slack page) and update meta.json + the channels.mdx links accordingly, so
there's one comprehensive Slack page and no duplicated manifest to drift.

Co-authored-by: multica-agent <github@multica.ai>

* docs(i18n): translate channel + Slack pages to zh/ja/ko and add to nav (MUL-3666)

Adds Simplified Chinese, Japanese, and Korean versions of channels.mdx and
slack-bot-integration.mdx, and lists both pages under Integrations in
meta.{zh,ja,ko}.json. The copy-paste manifest YAML and dotenv blocks are kept
byte-identical to the English source across all languages; in-page anchors in
the channel page point at the slug of each translated heading.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:33:23 +08:00
Bohan Jiang
658e63d9be fix: prefer local upload attachment URLs (#4686)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 14:49:04 +08:00
32 changed files with 862 additions and 143 deletions

View File

@@ -75,10 +75,6 @@ settings:
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
<Callout type="warning">
スコープに `users:read` を残しておいてください。接続時に Multica は `bots.info` を呼び出し、bot トークンと app-level トークンが**同じ**アプリのものであることを確認します——この呼び出しには `users:read` が必要です。これがないと、**Connect** は失敗します。
</Callout>
<Callout type="info">
Slack で特定の名前を表示したいですか? 作成前に `display_information.name` と `features.bot_user.display_name`(たとえばエージェントの名前に)を変更するか、あとで **App Home** で編集してください。Slack は Bot をその **bot display name** で表示しますが、これはアプリ名と異なる場合があります。
</Callout>

View File

@@ -75,10 +75,6 @@ settings:
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
<Callout type="warning">
스코프에 `users:read`를 유지하세요. 연결 시점에 Multica는 bot token과 app-level token이 **같은** 앱에서 왔는지 확인하기 위해 `bots.info`를 호출하는데 — 이 호출에는 `users:read`가 필요합니다. 이것이 없으면 **Connect**가 실패합니다.
</Callout>
<Callout type="info">
Slack에서 특정 이름을 쓰고 싶나요? 생성하기 전에 `display_information.name`과 `features.bot_user.display_name`을 (예: 에이전트 이름으로) 변경하거나, 나중에 **App Home**에서 편집하세요. Slack은 봇을 **bot display name**으로 표시하며, 이는 앱 이름과 다를 수 있습니다.
</Callout>

View File

@@ -75,10 +75,6 @@ This manifest configures everything Multica needs, so you don't set anything by
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
<Callout type="warning">
Keep `users:read` in the scopes. At connect time Multica calls `bots.info` to confirm the bot token and app-level token come from the **same** app — that call needs `users:read`. Without it, **Connect** fails.
</Callout>
<Callout type="info">
Want a specific name in Slack? Change `display_information.name` and `features.bot_user.display_name` (e.g. to your agent's name) before creating, or edit it later under **App Home**. Slack shows the bot by its **bot display name**, which can differ from the app name.
</Callout>

View File

@@ -75,10 +75,6 @@ settings:
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
<Callout type="warning">
请保留 scopes 里的 `users:read`。连接时 Multica 会调用 `bots.info` 来确认 bot token 和 app-level token 来自**同一个** app——这个调用需要 `users:read`。没有它,**Connect** 会失败。
</Callout>
<Callout type="info">
想在 Slack 里用一个特定的名字?在创建之前改 `display_information.name` 和 `features.bot_user.display_name`(比如改成你智能体的名字),或者之后在 **App Home** 里编辑。Slack 是按 Bot 的**显示名bot display name**来展示它的,这个名字可以和 app 名不一样。
</Callout>

View File

@@ -293,6 +293,26 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "Detach sub-Issues, sturdier daemon reconnects, and friendlier attachment previews",
changes: [],
features: [
"Issues now have a Remove parent action, so you can detach a sub-Issue without first having to pick a different parent.",
],
improvements: [
"The local daemon reconnects to Multica through a more resilient WebSocket flow with bounded backoff, so brief network drops recover smoothly instead of stalling.",
"The daemon now bounds each runtime probe with its own timeout, so a single wedged CLI can no longer block every other runtime from coming online.",
],
fixes: [
"Scheduled autopilots advance their next-run time the moment a run is dispatched, so a slow runner can no longer cause back-to-back duplicate dispatches.",
"Attachment previews open correctly whether the URL redirects inside a frame, comes back from the same origin, or was uploaded locally — and local upload URLs are now preferred when available.",
"When the failed-task handler unsticks an Issue, the Issue view refreshes immediately instead of waiting for a manual reload.",
"Sticky Issue comment headers share the same background fade as the highlight, so settling on a comment no longer looks out of sync.",
"Chat conversations refresh their message cache when reconnecting, so you no longer see stale messages right after coming back online.",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -269,6 +269,26 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "サブ Issue の切り離し、より堅牢なデーモン再接続、どこからでも開ける添付プレビュー",
changes: [],
features: [
"Issue のアクションに「親 Issue を解除」が追加され、別の親を選び直さなくても子 Issue を直接切り離せます。",
],
improvements: [
"ローカル デーモンの WebSocket 再接続が、上限付きのバックオフを備えたより堅牢な流れに見直され、瞬断にもスムーズに復帰します。",
"デーモンはランタイムのバージョン確認に個別のタイムアウトを設けるようになり、応答しない 1 つの CLI が他のランタイム起動を巻き込んで止めることがなくなりました。",
],
fixes: [
"予約オートパイロットはディスパッチ直後に次回実行時刻を進めるようになり、遅いランナーが同じ実行を続けて送り出すことがなくなりました。",
"添付プレビューは、フレーム内リダイレクト、同一オリジン、ローカル アップロードのいずれの場合も正しく開き、ローカル アップロード URL があるときはそちらを優先します。",
"失敗タスク ハンドラーが詰まった Issue を解除すると、Issue 表示が即座に更新され、手動リロードが不要になりました。",
"Issue コメントの sticky ヘッダーがハイライトのフェードと同じ背景遷移を共有し、固定切り替えの違和感がなくなりました。",
"Chat の会話は再接続時にメッセージ キャッシュを更新するため、オンラインに戻った直後に古いメッセージが残らなくなりました。",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -268,6 +268,26 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "하위 Issue 분리, 더 견고한 데몬 재연결, 어디서나 열리는 첨부 미리보기",
changes: [],
features: [
"Issue 액션에 '상위 Issue 해제'가 추가되어, 다른 상위를 먼저 고르지 않고도 하위 Issue를 즉시 분리할 수 있습니다.",
],
improvements: [
"로컬 데몬이 더 견고한 WebSocket 흐름과 상한이 있는 백오프로 재연결해, 짧은 네트워크 단절에도 매끄럽게 복구됩니다.",
"데몬이 각 런타임의 버전 점검에 별도 타임아웃을 두어, 멈춰 버린 단 하나의 CLI가 다른 런타임의 기동을 막지 못합니다.",
],
fixes: [
"예약 오토파일럿은 디스패치되자마자 다음 실행 시각을 앞당겨, 느린 러너가 같은 실행을 중복으로 내보내지 않습니다.",
"첨부 미리보기는 프레임 내 리다이렉트, 동일 출처, 로컬 업로드 어떤 경우에도 정상적으로 열리며, 로컬 업로드 URL이 있으면 그쪽을 우선 사용합니다.",
"실패 작업 핸들러가 멈춘 Issue를 풀어 줄 때 화면이 즉시 갱신되어, 수동 새로고침이 필요 없습니다.",
"Issue 댓글의 sticky 헤더가 하이라이트 페이드와 같은 배경 전환을 공유해, 고정 표시 전환이 더 이상 어색하지 않습니다.",
"Chat 대화가 재연결 시 메시지 캐시를 새로 받아, 오프라인에서 돌아왔을 때 오래된 메시지가 남지 않습니다.",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -293,6 +293,26 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.32",
date: "2026-06-29",
title: "支持解除父子 Issue、守护进程重连更稳附件预览处处可开",
changes: [],
features: [
"Issue 操作菜单新增「移除父级 Issue」可以直接断开父子关系不用先去挑一个新的父级。",
],
improvements: [
"本地守护进程的 WebSocket 重连改为带上限的退避策略,短暂断网时恢复更顺滑,不再原地空转。",
"守护进程在探测各个智能体运行时版本时加上了独立超时,单个卡死的 CLI 不会再连累其他运行时。",
],
fixes: [
"定时 Autopilot 调度后会立即推进下一次运行时间,避免慢节点造成重复触发。",
"附件预览在框架内重定向、同源资源、本地上传等场景下都能正常打开;有本地上传 URL 时会优先使用本地链接。",
"失败任务处理器解开卡住的 Issue 时,前端视图会立即刷新,无需手动重新加载。",
"Issue 评论吸顶头与高亮渐隐使用了同一套背景过渡,吸顶切换不再有错位感。",
"Chat 在重新连上后会刷新消息缓存,掉线再回来时不再看到陈旧消息。",
],
},
{
version: "0.3.31",
date: "2026-06-26",

View File

@@ -14,13 +14,17 @@ export const chatKeys = {
/** Full sessions list (active + archived); the dropdown splits locally. */
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
messagesPage: (sessionId: string) => ["chat", "messages-page", sessionId] as const,
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
messagesAll: () => ["chat", "messages"] as const,
messages: (sessionId: string) => [...chatKeys.messagesAll(), sessionId] as const,
messagesPageAll: () => ["chat", "messages-page"] as const,
messagesPage: (sessionId: string) => [...chatKeys.messagesPageAll(), sessionId] as const,
pendingTaskAll: () => ["chat", "pending-task"] as const,
pendingTask: (sessionId: string) => [...chatKeys.pendingTaskAll(), sessionId] as const,
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
/** Per-task execution messages — shared with issue agent cards. */
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
taskMessagesAll: () => ["task-messages"] as const,
taskMessages: (taskId: string) => [...chatKeys.taskMessagesAll(), taskId] as const,
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -102,9 +102,9 @@ describe("useRealtimeSync — ws instance change", () => {
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// + 1 cross-workspace inbox unread summary = 23 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(23);
// (15 workspace-scoped + 6 per-issue prefixes + 4 per-chat prefixes
// + 1 workspaceKeys.list() + 1 cross-workspace inbox unread summary = 27 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(27);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
@@ -164,4 +164,26 @@ describe("useRealtimeSync — ws instance change", () => {
expect(calls).toContainEqual(["issues", "attachments"]);
expect(calls).toContainEqual(["issues", "tasks"]);
});
it("invalidates per-chat-session caches (no wsId in key) on ws instance change", () => {
// These keys are not under the ["chat", wsId] prefix, so they need their
// own recovery invalidation when reconnecting after missed chat/task events.
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["chat", "messages"]);
expect(calls).toContainEqual(["chat", "messages-page"]);
expect(calls).toContainEqual(["chat", "pending-task"]);
expect(calls).toContainEqual(["task-messages"]);
});
});

View File

@@ -340,6 +340,14 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: issueKeys.usageAll() });
qc.invalidateQueries({ queryKey: issueKeys.attachmentsAll() });
qc.invalidateQueries({ queryKey: issueKeys.tasksAll() });
// Per-chat-session caches are also keyed without wsId, so the
// chatKeys.all(wsId) prefix above only reaches session lists / aggregates.
// Message streams rely on WS invalidation with staleTime: Infinity; recover
// sessions that missed chat/task events while the socket was disconnected.
qc.invalidateQueries({ queryKey: chatKeys.messagesAll() });
qc.invalidateQueries({ queryKey: chatKeys.messagesPageAll() });
qc.invalidateQueries({ queryKey: chatKeys.pendingTaskAll() });
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { BarChart3, FolderKanban } from "lucide-react";
import { BarChart3, FolderKanban, Trash2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
@@ -52,8 +52,9 @@ import {
aggregateDailyTokens,
aggregateWeeklyTasks,
aggregateWeeklyTime,
bucketUnknownAgentRows,
computeDailyTotals,
filterKnownAgentRows,
DELETED_AGENTS_ROW_ID,
formatDuration,
mergeAgentDashboardRows,
type AgentDashboardRow,
@@ -314,17 +315,29 @@ export function DashboardPage() {
[agentTokenRows, runTimeRows],
);
// Hide rollup rows for agents that were hard-deleted from the workspace —
// they'd otherwise show up as a bare UUID on the leaderboard (MUL-3771).
// Archived agents stay (the agent list is fetched with archived included);
// only truly-removed agents drop out. Skip filtering until the agent list
// has loaded so a slow agents fetch doesn't transiently blank the list.
// Fold rollup rows for hard-deleted agents into one aggregated "Deleted
// agents" row instead of showing them as a bare UUID (MUL-3771) or dropping
// them outright — dropping made the per-agent breakdown stop reconciling
// with the top-line Cost/Tokens KPIs, which still count that spend (MUL-3776,
// #4640). Archived agents stay as themselves (the agent list is fetched with
// archived included); only truly-removed agents collapse into the bucket.
// Skip bucketing until the agent list has loaded so a slow agents fetch
// doesn't transiently merge every row.
const knownAgentIds = useMemo(
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
[agentsQuery.isSuccess, agents],
);
const visibleAgentRows = useMemo(
() => filterKnownAgentRows(agentRows, knownAgentIds),
() => bucketUnknownAgentRows(agentRows, knownAgentIds),
[agentRows, knownAgentIds],
);
// Distinct hard-deleted agents folded into the bucket — drives the caption's
// "· N deleted" suffix (the bucket itself is a single row).
const deletedAgentCount = useMemo(
() =>
knownAgentIds
? agentRows.filter((r) => !knownAgentIds.has(r.agentId)).length
: 0,
[agentRows, knownAgentIds],
);
@@ -431,6 +444,7 @@ export function DashboardPage() {
<Leaderboard
rows={visibleAgentRows}
agents={agents}
deletedAgentCount={deletedAgentCount}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
</>
@@ -640,10 +654,12 @@ const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
function Leaderboard({
rows,
agents,
deletedAgentCount,
lessThanMinuteLabel,
}: {
rows: AgentDashboardRow[];
agents: { id: string; name: string }[];
deletedAgentCount: number;
lessThanMinuteLabel: string;
}) {
const { t } = useT("usage");
@@ -684,7 +700,12 @@ function Leaderboard({
<div className="flex items-center gap-3">
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
<span className="text-xs text-muted-foreground">
{t(($) => $.leaderboard.caption, { count: rows.length })}
{deletedAgentCount > 0
? t(($) => $.leaderboard.caption_with_deleted, {
count: rows.length - 1,
deleted: deletedAgentCount,
})
: t(($) => $.leaderboard.caption, { count: rows.length })}
</span>
</div>
</div>
@@ -704,6 +725,11 @@ function Leaderboard({
</div>
<div className="divide-y">
{sortedRows.map((row) => {
// The deleted-agents bucket is a synthetic row, not a real agent:
// render a neutral placeholder (no avatar fetch / hover card / UUID)
// and dash out Time/Tasks, which it never carries (see
// bucketUnknownAgentRows).
const isDeletedBucket = row.agentId === DELETED_AGENTS_ROW_ID;
const agent = agents.find((a) => a.id === row.agentId);
const value = SORT_METRIC[sortBy](row);
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
@@ -713,15 +739,28 @@ function Leaderboard({
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 px-4 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<ActorAvatar
actorType="agent"
actorId={row.agentId}
size={22}
enableHoverCard
/>
<span className="cursor-pointer truncate text-sm font-medium">
{agent?.name ?? row.agentId}
</span>
{isDeletedBucket ? (
<>
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Trash2 className="h-3 w-3" />
</span>
<span className="truncate text-sm font-medium italic text-muted-foreground">
{t(($) => $.leaderboard.deleted_agents)}
</span>
</>
) : (
<>
<ActorAvatar
actorType="agent"
actorId={row.agentId}
size={22}
enableHoverCard
/>
<span className="cursor-pointer truncate text-sm font-medium">
{agent?.name ?? row.agentId}
</span>
</>
)}
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
<div
@@ -742,12 +781,14 @@ function Leaderboard({
<div
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{formatDuration(row.seconds, lessThanMinuteLabel)}
{isDeletedBucket
? "—"
: formatDuration(row.seconds, lessThanMinuteLabel)}
</div>
<div
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{row.taskCount}
{isDeletedBucket ? "—" : row.taskCount}
</div>
</div>
);

View File

@@ -4,8 +4,9 @@ import {
aggregateDailyCost,
aggregateWeeklyTasks,
aggregateWeeklyTime,
bucketUnknownAgentRows,
computeDailyTotals,
filterKnownAgentRows,
DELETED_AGENTS_ROW_ID,
formatDuration,
mergeAgentDashboardRows,
} from "./utils";
@@ -202,26 +203,81 @@ describe("mergeAgentDashboardRows", () => {
});
});
describe("filterKnownAgentRows", () => {
const rows = [
{ agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 },
{ agentId: "deleted", tokens: 50, cost: 0.5, seconds: 5, taskCount: 1 },
];
describe("bucketUnknownAgentRows", () => {
const live = { agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 };
const archived = {
agentId: "archived",
tokens: 80,
cost: 0.8,
seconds: 8,
taskCount: 2,
};
const deletedA = {
agentId: "deleted-a",
tokens: 50,
cost: 0.5,
seconds: 5,
taskCount: 1,
};
const deletedB = {
agentId: "deleted-b",
tokens: 30,
cost: 0.25,
seconds: 3,
taskCount: 4,
};
it("drops rows whose agent is no longer in the workspace", () => {
// "deleted" is absent from the known set — it's a hard-deleted agent whose
// legacy rollup row would otherwise render as a bare UUID.
const out = filterKnownAgentRows(rows, new Set(["live"]));
expect(out.map((r) => r.agentId)).toEqual(["live"]);
it("folds every hard-deleted agent into one aggregated bucket row", () => {
// "deleted-a" / "deleted-b" are absent from the known set — they'd otherwise
// render as bare UUIDs. They collapse into a single sentinel row.
const out = bucketUnknownAgentRows(
[live, deletedA, deletedB],
new Set(["live"]),
);
expect(out.map((r) => r.agentId)).toEqual(["live", DELETED_AGENTS_ROW_ID]);
const bucket = out.find((r) => r.agentId === DELETED_AGENTS_ROW_ID)!;
expect(bucket.tokens).toBe(80);
expect(bucket.cost).toBeCloseTo(0.75);
// Time/Tasks never attach to the bucket — the run-time rollup inner-joins
// `agent`, so deleted agents contribute nothing to those columns.
expect(bucket.seconds).toBe(0);
expect(bucket.taskCount).toBe(0);
});
it("keeps every row while the agent list is still loading (null set)", () => {
const out = filterKnownAgentRows(rows, null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted"]);
it("keeps the bucket total reconciled with the top-line spend", () => {
// The KPI total counts deleted-agent spend; sum(visible rows) must match it
// so the breakdown reconciles (MUL-3776).
const out = bucketUnknownAgentRows(
[live, deletedA, deletedB],
new Set(["live"]),
);
const visibleCost = out.reduce((s, r) => s + r.cost, 0);
const kpiCost = [live, deletedA, deletedB].reduce((s, r) => s + r.cost, 0);
expect(visibleCost).toBeCloseTo(kpiCost);
});
it("drops every row when the known set is empty", () => {
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
it("keeps archived agents as themselves, never in the bucket", () => {
// The agent list is fetched with archived included, so archived agents are
// in the known set and stay on the board under their own id.
const out = bucketUnknownAgentRows(
[live, archived, deletedA],
new Set(["live", "archived"]),
);
expect(out.map((r) => r.agentId)).toEqual([
"live",
"archived",
DELETED_AGENTS_ROW_ID,
]);
});
it("adds no bucket row when every agent is known", () => {
const out = bucketUnknownAgentRows([live, archived], new Set(["live", "archived"]));
expect(out.map((r) => r.agentId)).toEqual(["live", "archived"]);
});
it("keeps every row untouched while the agent list is still loading (null set)", () => {
const out = bucketUnknownAgentRows([live, deletedA], null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted-a"]);
});
});

View File

@@ -227,21 +227,54 @@ export function mergeAgentDashboardRows(
});
}
// Drop usage rows whose agent no longer exists in the workspace. The agent
// list is fetched with `include_archived: true`, so archived agents keep
// their names and stay on the leaderboard; only hard-deleted agents fall out
// of `knownAgentIds`. Those are legacy rollup rows that would otherwise
// render as a bare UUID (MUL-3771).
// Synthetic agentId for the row that aggregates all hard-deleted agents.
// Sentinel (not a real UUID) so the component can detect it and render a
// placeholder instead of looking the id up in the agent list.
export const DELETED_AGENTS_ROW_ID = "__deleted_agents__";
// Fold usage rows whose agent no longer exists in the workspace into a single
// aggregated "Deleted agents" row instead of dropping them. The agent list is
// fetched with `include_archived: true`, so archived agents keep their names
// and stay on the leaderboard as themselves; only hard-deleted agents fall out
// of `knownAgentIds` and collapse into the bucket.
//
// `knownAgentIds` is empty while the agent list is still loading; callers
// MUL-3771 (PR #4637) originally *dropped* these rows so they'd stop rendering
// as a bare UUID — but the top-line Cost/Tokens KPIs still count their spend
// (those totals aggregate `task_usage_hourly` without joining `agent`), so the
// per-agent breakdown no longer reconciled with the totals (MUL-3776, #4640).
// Aggregating instead of dropping keeps `sum(visible rows) == KPI total` while
// still never exposing a UUID. The bucket carries tokens + cost only; seconds
// and taskCount stay 0 because the run-time rollups inner-join `agent`, so
// deleted agents already contribute nothing to the Time/Tasks KPIs — the
// component renders those two columns as "—" for this row.
//
// `knownAgentIds` is `null` while the agent list is still loading; callers
// pass `null` in that case so the rows pass through untouched instead of the
// whole leaderboard blanking on a slow fetch.
export function filterKnownAgentRows(
// whole leaderboard collapsing into one bucket on a slow fetch.
export function bucketUnknownAgentRows(
rows: AgentDashboardRow[],
knownAgentIds: ReadonlySet<string> | null,
): AgentDashboardRow[] {
if (!knownAgentIds) return rows;
return rows.filter((r) => knownAgentIds.has(r.agentId));
const known: AgentDashboardRow[] = [];
const bucket: AgentDashboardRow = {
agentId: DELETED_AGENTS_ROW_ID,
tokens: 0,
cost: 0,
seconds: 0,
taskCount: 0,
};
let hasDeleted = false;
for (const r of rows) {
if (knownAgentIds.has(r.agentId)) {
known.push(r);
continue;
}
hasDeleted = true;
bucket.tokens += r.tokens;
bucket.cost += r.cost;
}
return hasDeleted ? [...known, bucket] : known;
}
// ---------------------------------------------------------------------------

View File

@@ -262,6 +262,40 @@ describe("Attachment — image dispatch", () => {
);
});
it("prefers a local disk /uploads URL over API markdown in split-origin self-host", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test");
const id = "11111111-2222-3333-4444-555555555555";
const markdownUrl = `https://api.example.test/api/attachments/${id}/download`;
const mediaUrl = "https://api.example.test/uploads/workspaces/ws-1/shot.png";
const att = makeRecord({
id,
url: "/uploads/workspaces/ws-1/shot.png",
markdown_url: markdownUrl,
download_url: `/api/attachments/${id}/download`,
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
expect(document.querySelector("img")?.getAttribute("src")).toBe(mediaUrl);
fireEvent.click(screen.getByTitle("View"));
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
img.getAttribute("src"),
);
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
});
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
configStore.setState({ cdnDomain: "cdn.example.test" });
const id = "11111111-2222-3333-4444-555555555555";

View File

@@ -237,12 +237,17 @@ function absolutizeMediaURL(rawUrl: string): string {
// reports `cdn_signed` — in CloudFront signed-URL mode the same
// domain serves PRIVATE content and a raw (unsigned) storage URL is
// a guaranteed 403 (MUL-3254).
// 3. `record.markdown_url` — the durable, server-policy-aligned URL.
// 3. Local disk `record.url` — self-host LocalStorage without
// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path.
// It is the direct static object URL and is loadable once
// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients.
// 4. `record.markdown_url` — the durable, server-policy-aligned URL.
// Beats raw `record.url` because it never points at a private
// bucket (must-fix 2 from MUL-3192 review).
// 4. `record.url` — legacy fallback for responses that omit
// bucket (must-fix 2 from MUL-3192 review), except for the explicit
// site-relative local upload path above.
// 5. `record.url` — legacy fallback for responses that omit
// `markdown_url` (a backend old enough to predate MUL-3192).
// 5. The input URL — when there's no record at all.
// 6. The input URL — when there's no record at all.
function pickInlineMediaURL(
record: AttachmentRecord,
fallback: string,
@@ -257,11 +262,18 @@ function pickInlineMediaURL(
return dl;
}
if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
if (isSiteRelativeLocalUploadURL(record.url)) return record.url;
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}
function isSiteRelativeLocalUploadURL(rawURL: string): boolean {
if (!rawURL || !rawURL.startsWith("/")) return false;
const path = rawURL.split(/[?#]/, 1)[0] ?? "";
return path === "/uploads" || path.startsWith("/uploads/");
}
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
const expected = normalizeHost(cdnDomain);
if (!rawURL || !expected) return false;

View File

@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "Connect each Multica Agent to its own Slack bot. A workspace admin creates a Slack app and pastes its bot + app-level tokens; members can then DM the bot or @mention it in a channel, and start a message with /issue (e.g. \"@bot /issue Fix the login bug\") to spin up a new Multica issue.",
"page_description": "Connect each Multica Agent to its own Slack bot. Members can DM the bot, @mention it in a channel, and type /issue to spin up a new Multica issue.",
"not_enabled_title": "Slack integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
@@ -333,13 +333,10 @@
"agent_bot_manage_link": "Open in Slack",
"agent_bot_manage_tooltip": "Open this bot's Slack workspace.",
"byo_dialog_title": "Connect a Slack bot",
"byo_dialog_intro": "Create your own Slack app, install it to your workspace, then paste its two tokens below. You can connect a different app for each agent in the same workspace.",
"byo_video_cta": "Watch the setup walkthrough",
"byo_docs_link": "Step-by-step: connect your Multica agent to Slack",
"byo_bot_token_label": "Bot token (xoxb-)",
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token.",
"byo_app_token_label": "App-level token (xapp-)",
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokens (scope connections:write).",
"byo_scopes_hint": "Required bot scopes: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
"byo_submit": "Connect",
"byo_submitting": "Connecting…",
"byo_cancel": "Cancel",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "Leaderboard",
"caption": "{{count}} agents",
"caption_with_deleted": "{{count}} agents · {{deleted}} deleted",
"deleted_agents": "Deleted agents",
"header_agent": "Agent",
"header_tokens": "Tokens",
"header_cost": "Cost",

View File

@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。ワークスペース管理者が Slack アプリを作成し、その bot トークンと app レベルトークンを貼り付けます。メンバーはボットに DM したりチャンネルで @メンションしたりでき、/issue で始まるメッセージ(例:「@bot /issue ログインの不具合を修正」)で新しい Multica issue を作成できます。",
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。メンバーはボットに DM したりチャンネルで @メンションしたり、/issue と入力して新しい Multica イシューを起こすことができます。",
"not_enabled_title": "Slack 連携が有効になっていません",
"not_enabled_description_prefix": "サーバーで",
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
@@ -333,13 +333,10 @@
"agent_bot_manage_link": "Slack で開く",
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
"byo_dialog_title": "Slack ボットを接続",
"byo_dialog_intro": "自分の Slack アプリを作成してワークスペースにインストールし、その 2 つのトークンを下に貼り付けてください。同じワークスペース内でエージェントごとに別のアプリを接続できます。",
"byo_video_cta": "セットアップ手順の動画を見る",
"byo_docs_link": "Step-by-stepMultica エージェントを Slack に接続する",
"byo_bot_token_label": "Bot トークンxoxb-",
"byo_bot_token_hint": "Slack アプリ → OAuth & Permissions → Bot User OAuth Token。",
"byo_app_token_label": "App レベルトークンxapp-",
"byo_app_token_hint": "Slack アプリ → Basic Information → App-Level Tokensスコープ connections:write。",
"byo_scopes_hint": "必要な Bot スコープapp_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
"byo_submit": "接続",
"byo_submitting": "接続中…",
"byo_cancel": "キャンセル",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "リーダーボード",
"caption": "{{count}} 件のエージェント",
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
"deleted_agents": "削除済みエージェント",
"header_agent": "エージェント",
"header_tokens": "トークン",
"header_cost": "コスト",

View File

@@ -379,7 +379,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결합니다. 워크스페이스 관리자가 Slack 앱을 만들고 봇 토큰과 app 레벨 토큰을 붙여넣으면, 멤버는 봇에게 DM하거나 채널에서 @멘션할 수 있습니다. /issue로 시작하는 메시지(예: \"@bot /issue 로그인 버그 수정\")로 새 Multica 이슈를 만들 수 있어요.",
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결하세요. 멤버는 봇과 1:1로 대화하거나, 채널에서 @ 멘션하거나, /issue 를 입력해 새 Multica 이슈를 만들 수 있습니다.",
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
"not_enabled_description_prefix": "서버에서",
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
@@ -410,13 +410,10 @@
"agent_bot_manage_link": "Slack에서 열기",
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
"byo_dialog_title": "Slack 봇 연결",
"byo_dialog_intro": "직접 만든 Slack 앱을 워크스페이스에 설치한 뒤, 두 개의 토큰을 아래에 붙여넣으세요. 같은 워크스페이스에서 에이전트마다 다른 앱을 연결할 수 있습니다.",
"byo_video_cta": "설정 안내 영상 보기",
"byo_docs_link": "Step-by-step: Multica 에이전트를 Slack에 연결하기",
"byo_bot_token_label": "Bot 토큰(xoxb-)",
"byo_bot_token_hint": "Slack 앱 → OAuth & Permissions → Bot User OAuth Token.",
"byo_app_token_label": "App 레벨 토큰(xapp-)",
"byo_app_token_hint": "Slack 앱 → Basic Information → App-Level Tokens(스코프 connections:write).",
"byo_scopes_hint": "필요한 Bot 스코프: app_mentions:read, channels:history, chat:write, groups:history, im:history, mpim:history, users:read.",
"byo_submit": "연결",
"byo_submitting": "연결 중…",
"byo_cancel": "취소",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "리더보드",
"caption": "에이전트 {{count}}개",
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
"deleted_agents": "삭제된 에이전트",
"header_agent": "에이전트",
"header_tokens": "토큰",
"header_cost": "비용",

View File

@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
"page_description": "每个 Multica Agent 连接到它自己的 Slack 机器人。工作区管理员创建一个 Slack app 并粘贴它的 bot 和 app-level token成员之后即可私聊机器人,或在频道中 @ 它,并以 /issue 开头发消息(例如「@机器人 /issue 修复登录问题」)来创建新的 Multica issue。",
"page_description": "每个 Multica 智能体连接到专属的 Slack 机器人。成员可私聊机器人在频道中 @ 它,或输入 /issue 直接创建 Multica issue。",
"not_enabled_title": "Slack 集成未启用",
"not_enabled_description_prefix": "在服务器上设置",
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
@@ -333,13 +333,10 @@
"agent_bot_manage_link": "在 Slack 中打开",
"agent_bot_manage_tooltip": "打开此机器人所在的 Slack 工作区。",
"byo_dialog_title": "连接 Slack 机器人",
"byo_dialog_intro": "创建你自己的 Slack app安装到你的工作区然后把它的两个 token 粘贴到下面。同一个工作区里,每个 agent 可以连接不同的 app。",
"byo_video_cta": "观看配置教程视频",
"byo_docs_link": "Step-by-step把你的 Multica 智能体连接到 Slack",
"byo_bot_token_label": "Bot tokenxoxb-",
"byo_bot_token_hint": "Slack app → OAuth & Permissions → Bot User OAuth Token。",
"byo_app_token_label": "App-level tokenxapp-",
"byo_app_token_hint": "Slack app → Basic Information → App-Level Tokensscope 选 connections:write。",
"byo_scopes_hint": "需要的 Bot scopesapp_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
"byo_submit": "连接",
"byo_submitting": "连接中…",
"byo_cancel": "取消",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "排行榜",
"caption": "{{count}} 个智能体",
"caption_with_deleted": "{{count}} 个智能体 · {{deleted}} 个已删除",
"deleted_agents": "已删除的智能体",
"header_agent": "智能体",
"header_tokens": "Token",
"header_cost": "费用",

View File

@@ -10,7 +10,6 @@ import { Card, CardContent } from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -242,6 +241,21 @@ function InstallationRow({
// shows how to create the Slack app + copy its two tokens is recorded.
const SLACK_BYO_VIDEO_URL = "";
// slackDocsUrl points at the Slack integration guide on the docs site,
// localized to the viewer's language. The docs site uses /<lang>/ path
// prefixes (English has none), matching the convention used elsewhere in the
// app for doc links (e.g. the autopilots webhook docs link).
function slackDocsUrl(lang: string | undefined): string {
const prefix = lang?.startsWith("zh")
? "/zh"
: lang?.startsWith("ja")
? "/ja"
: lang?.startsWith("ko")
? "/ko"
: "";
return `https://multica.ai/docs${prefix}/slack-bot-integration`;
}
// SlackAgentBindButton is the per-agent CTA exposed from the agent detail page.
// Slack uses the bring-your-own-app model: the button opens a dialog where the
// admin pastes the bot token (xoxb-) + app-level token (xapp-) of the Slack app
@@ -267,7 +281,7 @@ export function SlackAgentBindButton({
*/
onShowConnectedDetails?: () => void;
}) {
const { t } = useT("settings");
const { t, i18n } = useT("settings");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
@@ -370,25 +384,28 @@ export function SlackAgentBindButton({
<DialogContent className="sm:max-w-lg" data-testid="slack-byo-dialog">
<DialogHeader>
<DialogTitle>{t(($) => $.slack.byo_dialog_title)}</DialogTitle>
<DialogDescription>
{t(($) => $.slack.byo_dialog_intro)}
</DialogDescription>
</DialogHeader>
{SLACK_BYO_VIDEO_URL ? (
<button
type="button"
onClick={() => openExternal(SLACK_BYO_VIDEO_URL)}
className="inline-flex w-fit items-center gap-1.5 text-xs font-medium text-primary underline-offset-2 hover:underline"
className="inline-flex w-fit items-center gap-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
<ExternalLink className="h-4 w-4" />
{t(($) => $.slack.byo_video_cta)}
</button>
) : null}
<p className="rounded-md bg-muted px-3 py-2 text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_scopes_hint)}
</p>
<button
type="button"
onClick={() => openExternal(slackDocsUrl(i18n.language))}
className="inline-flex w-fit items-center gap-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
data-testid="slack-byo-docs-link"
>
<ExternalLink className="h-4 w-4" />
{t(($) => $.slack.byo_docs_link)}
</button>
<div className="space-y-4">
<div className="space-y-1.5">
@@ -405,9 +422,6 @@ export function SlackAgentBindButton({
spellCheck={false}
disabled={submitting}
/>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_bot_token_hint)}
</p>
</div>
<div className="space-y-1.5">
@@ -424,9 +438,6 @@ export function SlackAgentBindButton({
spellCheck={false}
disabled={submitting}
/>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.slack.byo_app_token_hint)}
</p>
</div>
</div>

View File

@@ -444,10 +444,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
slackBindingSvc := slack.NewBindingTokenService(queries, pool)
h.SlackBindingTokens = slackBindingSvc
slackReplier := slack.NewOutboundReplier(slack.OutboundReplierConfig{
Binding: slackBindingSvc,
Decrypt: box.Open,
PublicURL: signupConfig.PublicURL,
Logger: slog.Default(),
Binding: slackBindingSvc,
Decrypt: box.Open,
// The bind link (/slack/bind) is a web-app page, so it must use the
// app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), NOT MULTICA_PUBLIC_URL
// (the backend/API URL). Mirrors the Lark replier (appURLFromEnv).
AppURL: appURLFromEnv(),
Logger: slog.Default(),
})
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool, slackReplier))
slack.NewOutbound(queries, box.Open, slog.Default()).Register(bus)

View File

@@ -110,13 +110,30 @@ type Client struct {
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
client: &http.Client{Timeout: 30 * time.Second, Transport: cloneDefaultTransport()},
bundleClient: &http.Client{},
platform: "daemon",
os: normalizeGOOS(runtime.GOOS),
}
}
func cloneDefaultTransport() http.RoundTripper {
if transport, ok := http.DefaultTransport.(*http.Transport); ok {
return transport.Clone()
}
return http.DefaultTransport
}
// CloseIdleConnections drops pooled control-plane HTTP connections. The
// daemon calls this after repeated heartbeat transport failures so a stale
// keep-alive socket from a server restart cannot delay recovery indefinitely.
func (c *Client) CloseIdleConnections() {
if c == nil || c.client == nil {
return
}
c.client.CloseIdleConnections()
}
// normalizeGOOS maps Go's runtime.GOOS values to the protocol vocabulary
// used by X-Client-OS / client_os ("macos" / "windows" / "linux").
func normalizeGOOS(goos string) string {

View File

@@ -1905,7 +1905,19 @@ func (d *Daemon) runRuntimeHeartbeat(ctx context.Context, rid string) {
}
}
d.runHeartbeatTick(ctx, rid)
consecutiveTransientFailures := 0
tick := func() {
if d.runHeartbeatTick(ctx, rid) {
consecutiveTransientFailures++
if consecutiveTransientFailures == 2 {
d.client.CloseIdleConnections()
}
return
}
consecutiveTransientFailures = 0
}
tick()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -1914,12 +1926,14 @@ func (d *Daemon) runRuntimeHeartbeat(ctx context.Context, rid string) {
case <-ctx.Done():
return
case <-ticker.C:
d.runHeartbeatTick(ctx, rid)
tick()
}
}
}
func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) {
// runHeartbeatTick returns true when the HTTP heartbeat hit a transient
// failure that should count toward stale idle-connection cleanup.
func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) bool {
// Skip HTTP heartbeat for runtimes that successfully acked a recent
// WebSocket heartbeat. The WS path keeps last_seen_at fresh and delivers
// actions, so the HTTP write would be a duplicate DB update. If the WS
@@ -1928,7 +1942,7 @@ func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) {
// relies on.
if d.wsHeartbeatRecentlyAcked(rid) {
d.logger.Debug("heartbeat: skipping HTTP tick, WS recently acked", "runtime_id", rid)
return
return false
}
d.logger.Debug("heartbeat: HTTP tick", "runtime_id", rid)
resp, err := d.client.SendHeartbeat(ctx, rid)
@@ -1941,20 +1955,21 @@ func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) {
// the daemon root context so notifyRuntimeSetChanged
// tearing down this heartbeat goroutine cannot abort it.
go d.handleRuntimeGone(rid)
return
return false
}
d.logger.Warn("heartbeat failed", "runtime_id", rid, "error", err)
}
return
return ctx.Err() == nil && isTransientError(err)
}
if resp != nil && resp.RuntimeGone {
// The WS path returns a successful ack with RuntimeGone=true for the
// same scenario; treat it the same way here in case HTTP starts
// surfacing this signal too.
go d.handleRuntimeGone(rid)
return
return false
}
d.handleHeartbeatActions(ctx, rid, resp)
return false
}
// handleHeartbeatActions dispatches the pending-action set returned by either

View File

@@ -18,6 +18,14 @@ import (
var errRuntimeSetChanged = errors.New("runtime set changed")
const taskWakeupMaxBackoff = 30 * time.Second
var (
taskWakeupPongWait = 60 * time.Second
taskWakeupWriteWait = 10 * time.Second
taskWakeupBackoffResetAfter = 10 * time.Second
)
type taskWakeup struct {
runtimeID string
}
@@ -36,7 +44,7 @@ func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- taskWake
continue
}
err := d.runTaskWakeupConnection(ctx, runtimeIDs, taskWakeups, runtimeSetCh)
connectedFor, err := d.runTaskWakeupConnection(ctx, runtimeIDs, taskWakeups, runtimeSetCh)
if ctx.Err() != nil {
return
}
@@ -44,6 +52,9 @@ func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- taskWake
backoff = time.Second
continue
}
if shouldResetTaskWakeupBackoff(connectedFor) {
backoff = time.Second
}
if err != nil {
d.logger.Debug("task wakeup websocket unavailable; polling fallback remains active", "error", err, "retry_in", backoff)
}
@@ -51,15 +62,22 @@ func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- taskWake
if err := sleepWithContextOrRuntimeChange(ctx, jitterDuration(backoff), runtimeSetCh); err != nil {
return
}
if backoff < 30*time.Second {
if backoff < taskWakeupMaxBackoff {
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
if backoff > taskWakeupMaxBackoff {
backoff = taskWakeupMaxBackoff
}
}
}
}
func shouldResetTaskWakeupBackoff(connectedFor time.Duration) bool {
if connectedFor <= 0 {
return false
}
return taskWakeupBackoffResetAfter <= 0 || connectedFor >= taskWakeupBackoffResetAfter
}
func jitterDuration(d time.Duration) time.Duration {
if d <= 0 {
return d
@@ -72,10 +90,10 @@ func jitterDuration(d time.Duration) time.Duration {
return d + delta
}
func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []string, taskWakeups chan<- taskWakeup, runtimeSetCh <-chan struct{}) error {
func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []string, taskWakeups chan<- taskWakeup, runtimeSetCh <-chan struct{}) (time.Duration, error) {
wsURL, err := taskWakeupURL(d.cfg.ServerBaseURL, runtimeIDs)
if err != nil {
return err
return 0, err
}
headers := http.Header{}
@@ -95,8 +113,10 @@ func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []strin
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.DialContext(ctx, wsURL, headers)
if err != nil {
return err
return 0, err
}
connectedAt := time.Now()
uptime := func() time.Duration { return time.Since(connectedAt) }
defer conn.Close()
// HTTP heartbeats resume the moment WS detaches so the freshness window
// from a previous connection cannot keep them silenced past disconnect.
@@ -151,11 +171,11 @@ func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []strin
select {
case <-ctx.Done():
return ctx.Err()
return uptime(), ctx.Err()
case <-runtimeSetCh:
return errRuntimeSetChanged
return uptime(), errRuntimeSetChanged
case err := <-errCh:
return err
return uptime(), err
}
}
@@ -260,12 +280,15 @@ func (d *Daemon) handleWSHeartbeatAck(ctx context.Context, ack *HeartbeatRespons
}
func (d *Daemon) readTaskWakeupMessages(conn *websocket.Conn, taskWakeups chan<- taskWakeup) error {
conn.SetReadLimit(64 * 1024)
d.configureTaskWakeupReadLiveness(conn)
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return err
}
if err := d.extendTaskWakeupReadDeadline(conn); err != nil {
return err
}
var msg protocol.Message
if err := json.Unmarshal(raw, &msg); err != nil {
d.logger.Debug("task wakeup websocket invalid message", "error", err)
@@ -306,6 +329,26 @@ func (d *Daemon) readTaskWakeupMessages(conn *websocket.Conn, taskWakeups chan<-
}
}
func (d *Daemon) configureTaskWakeupReadLiveness(conn *websocket.Conn) {
conn.SetReadLimit(64 * 1024)
if err := d.extendTaskWakeupReadDeadline(conn); err != nil {
d.logger.Debug("task wakeup websocket read deadline failed", "error", err)
}
conn.SetPongHandler(func(string) error {
return d.extendTaskWakeupReadDeadline(conn)
})
conn.SetPingHandler(func(appData string) error {
if err := d.extendTaskWakeupReadDeadline(conn); err != nil {
return err
}
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(taskWakeupWriteWait))
})
}
func (d *Daemon) extendTaskWakeupReadDeadline(conn *websocket.Conn) error {
return conn.SetReadDeadline(time.Now().Add(taskWakeupPongWait))
}
func (d *Daemon) handleRuntimeProfilesChanged(payload protocol.RuntimeProfilesChangedPayload) {
if payload.WorkspaceID == "" {
return

View File

@@ -1,9 +1,20 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func TestTaskWakeupURL(t *testing.T) {
@@ -75,3 +86,345 @@ func TestWSHeartbeatFreshnessSuppressesHTTP(t *testing.T) {
t.Fatalf("expected clearWSHeartbeatAcks to drop all entries")
}
}
func TestReadTaskWakeupMessagesTimesOutWithoutPeerTraffic(t *testing.T) {
overrideTaskWakeupTimings(t, 60*time.Millisecond, 20*time.Millisecond, taskWakeupBackoffResetAfter)
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
time.Sleep(300 * time.Millisecond)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, make(chan taskWakeup, 1))
}()
select {
case err := <-errCh:
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("readTaskWakeupMessages error = %v, want timeout", err)
}
case <-time.After(time.Second):
t.Fatal("readTaskWakeupMessages did not time out")
}
}
func TestReadTaskWakeupMessagesExtendsDeadlineOnServerPing(t *testing.T) {
overrideTaskWakeupTimings(t, 120*time.Millisecond, 50*time.Millisecond, taskWakeupBackoffResetAfter)
clientReceived := make(chan struct{})
taskFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonTaskAvailable,
Payload: marshalRaw(protocol.TaskAvailablePayload{
RuntimeID: "runtime-1",
TaskID: "task-1",
}),
})
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 3; i++ {
time.Sleep(50 * time.Millisecond)
conn.SetWriteDeadline(time.Now().Add(50 * time.Millisecond))
if err := conn.WriteMessage(websocket.PingMessage, []byte("keepalive")); err != nil {
return
}
}
if !writeWSMessage(t, conn, websocket.TextMessage, taskFrame) {
return
}
waitForClientWakeup(t, clientReceived)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
taskWakeups := make(chan taskWakeup, 1)
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
}()
select {
case wakeup := <-taskWakeups:
if wakeup.runtimeID != "runtime-1" {
t.Fatalf("wakeup runtimeID = %q, want runtime-1", wakeup.runtimeID)
}
close(clientReceived)
case err := <-errCh:
t.Fatalf("readTaskWakeupMessages returned before task frame: %v", err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task wakeup")
}
}
func TestReadTaskWakeupMessagesExtendsDeadlineOnApplicationMessage(t *testing.T) {
overrideTaskWakeupTimings(t, 120*time.Millisecond, 50*time.Millisecond, taskWakeupBackoffResetAfter)
clientReceived := make(chan struct{})
ackFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonHeartbeatAck,
Payload: marshalRaw(HeartbeatResponse{
RuntimeID: "runtime-1",
}),
})
taskFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonTaskAvailable,
Payload: marshalRaw(protocol.TaskAvailablePayload{
RuntimeID: "runtime-1",
TaskID: "task-1",
}),
})
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 3; i++ {
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.TextMessage, ackFrame) {
return
}
}
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.TextMessage, taskFrame) {
return
}
waitForClientWakeup(t, clientReceived)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
taskWakeups := make(chan taskWakeup, 1)
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
}()
select {
case wakeup := <-taskWakeups:
if wakeup.runtimeID != "runtime-1" {
t.Fatalf("wakeup runtimeID = %q, want runtime-1", wakeup.runtimeID)
}
close(clientReceived)
case err := <-errCh:
t.Fatalf("readTaskWakeupMessages returned before task frame: %v", err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task wakeup")
}
}
func TestReadTaskWakeupMessagesExtendsDeadlineOnPong(t *testing.T) {
overrideTaskWakeupTimings(t, 120*time.Millisecond, 50*time.Millisecond, taskWakeupBackoffResetAfter)
clientReceived := make(chan struct{})
taskFrame := mustProtocolFrame(t, protocol.Message{
Type: protocol.EventDaemonTaskAvailable,
Payload: marshalRaw(protocol.TaskAvailablePayload{
RuntimeID: "runtime-1",
TaskID: "task-1",
}),
})
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 3; i++ {
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.PongMessage, []byte("keepalive")) {
return
}
}
time.Sleep(50 * time.Millisecond)
if !writeWSMessage(t, conn, websocket.TextMessage, taskFrame) {
return
}
waitForClientWakeup(t, clientReceived)
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial(taskWakeupTestWSURL(srv.URL), nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
d := New(Config{}, slog.Default())
taskWakeups := make(chan taskWakeup, 1)
errCh := make(chan error, 1)
go func() {
errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
}()
select {
case wakeup := <-taskWakeups:
if wakeup.runtimeID != "runtime-1" {
t.Fatalf("wakeup runtimeID = %q, want runtime-1", wakeup.runtimeID)
}
close(clientReceived)
case err := <-errCh:
t.Fatalf("readTaskWakeupMessages returned before task frame: %v", err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for task wakeup")
}
}
func TestShouldResetTaskWakeupBackoffRequiresStableConnection(t *testing.T) {
old := taskWakeupBackoffResetAfter
taskWakeupBackoffResetAfter = 10 * time.Second
t.Cleanup(func() {
taskWakeupBackoffResetAfter = old
})
if shouldResetTaskWakeupBackoff(0) {
t.Fatal("zero connection uptime reset backoff")
}
if shouldResetTaskWakeupBackoff(9 * time.Second) {
t.Fatal("short connection uptime reset backoff")
}
if !shouldResetTaskWakeupBackoff(10 * time.Second) {
t.Fatal("stable connection uptime did not reset backoff")
}
}
func TestRuntimeHeartbeatClosesIdleConnectionsAfterRepeatedTransientFailures(t *testing.T) {
transport := &closeCountingTransport{}
client := NewClient("http://daemon.test")
client.client = &http.Client{
Timeout: time.Second,
Transport: transport,
}
d := New(Config{HeartbeatInterval: 10 * time.Millisecond}, slog.Default())
d.client = client
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
d.runRuntimeHeartbeat(ctx, "runtime-1")
}()
deadline := time.After(time.Second)
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
for transport.closeCount.Load() == 0 {
select {
case <-ticker.C:
case <-deadline:
cancel()
t.Fatal("CloseIdleConnections was not called")
}
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("runRuntimeHeartbeat did not stop after context cancellation")
}
if got := transport.roundTrips.Load(); got < 2 {
t.Fatalf("RoundTrip count = %d, want at least 2", got)
}
}
type closeCountingTransport struct {
roundTrips atomic.Int32
closeCount atomic.Int32
}
func (t *closeCountingTransport) RoundTrip(*http.Request) (*http.Response, error) {
t.roundTrips.Add(1)
return nil, errors.New("dial failed")
}
func (t *closeCountingTransport) CloseIdleConnections() {
t.closeCount.Add(1)
}
func overrideTaskWakeupTimings(t *testing.T, pongWait, writeWait, backoffResetAfter time.Duration) {
t.Helper()
oldPongWait := taskWakeupPongWait
oldWriteWait := taskWakeupWriteWait
oldBackoffResetAfter := taskWakeupBackoffResetAfter
taskWakeupPongWait = pongWait
taskWakeupWriteWait = writeWait
taskWakeupBackoffResetAfter = backoffResetAfter
t.Cleanup(func() {
taskWakeupPongWait = oldPongWait
taskWakeupWriteWait = oldWriteWait
taskWakeupBackoffResetAfter = oldBackoffResetAfter
})
}
func taskWakeupTestWSURL(httpURL string) string {
return strings.Replace(httpURL, "http", "ws", 1)
}
func mustProtocolFrame(t *testing.T, msg protocol.Message) []byte {
t.Helper()
frame, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal websocket frame: %v", err)
}
return frame
}
func writeWSMessage(t *testing.T, conn *websocket.Conn, messageType int, frame []byte) bool {
t.Helper()
conn.SetWriteDeadline(time.Now().Add(50 * time.Millisecond))
if err := conn.WriteMessage(messageType, frame); err != nil {
t.Errorf("write websocket frame: %v", err)
return false
}
return true
}
func waitForClientWakeup(t *testing.T, clientReceived <-chan struct{}) {
t.Helper()
select {
case <-clientReceived:
case <-time.After(time.Second):
t.Errorf("server timed out waiting for client wakeup")
}
}

View File

@@ -46,18 +46,25 @@ type OutboundReplier struct {
binding bindingMinter
decrypt Decrypter
newSender func(creds credentials) replySender
publicURL string
appURL string
bindingPath string
logger *slog.Logger
}
// OutboundReplierConfig configures the replier. Binding + PublicURL are required
// OutboundReplierConfig configures the replier. Binding + AppURL are required
// for the NeedsBinding prompt to work; without them the prompt is skipped (the
// offline/archived/issue notices still fire).
type OutboundReplierConfig struct {
Binding bindingMinter
Decrypt Decrypter
PublicURL string
Binding bindingMinter
Decrypt Decrypter
// AppURL is the Multica web app host the user clicks into to redeem the
// binding token (e.g. https://multica.example). It comes from MULTICA_APP_URL
// (falling back to FRONTEND_ORIGIN) and is intentionally separate from
// MULTICA_PUBLIC_URL, which is the backend/API public URL used for webhook and
// daemon-facing endpoints — the bind page (/slack/bind) is served by the web
// app, so the link must point at the app host, not the API host. Mirrors the
// Lark replier's AppURL.
AppURL string
BindingPath string // default "/slack/bind"
Logger *slog.Logger
}
@@ -81,7 +88,7 @@ func NewOutboundReplier(cfg OutboundReplierConfig) *OutboundReplier {
r := &OutboundReplier{
binding: cfg.Binding,
decrypt: cfg.Decrypt,
publicURL: strings.TrimRight(cfg.PublicURL, "/"),
appURL: strings.TrimRight(cfg.AppURL, "/"),
bindingPath: bindingPath,
logger: logger,
}
@@ -133,14 +140,14 @@ func (r *OutboundReplier) sendBindingPrompt(ctx context.Context, inst engine.Res
if r.binding == nil {
return errors.New("binding service not configured")
}
if r.publicURL == "" {
return errors.New("public url not configured")
if r.appURL == "" {
return errors.New("app url not configured")
}
token, err := r.binding.Mint(ctx, inst.WorkspaceID, inst.ID, sender)
if err != nil {
return fmt.Errorf("mint binding token: %w", err)
}
bindURL := r.publicURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
bindURL := r.appURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
// Wrap the URL as an explicit Slack link <url|label>: formatMrkdwn protects
// these from its markdown passes, so the base64url token's `_`/`-` chars are
// not mangled into italics.

View File

@@ -41,9 +41,9 @@ func (f *fakeBindingMinter) Mint(_ context.Context, ws, inst pgtype.UUID, user s
func newTestReplier(binding bindingMinter, sender replySender) *OutboundReplier {
r := NewOutboundReplier(OutboundReplierConfig{
Binding: binding,
Decrypt: nil, // identity: stored bot token is base64 plaintext
PublicURL: "https://multica.example",
Binding: binding,
Decrypt: nil, // identity: stored bot token is base64 plaintext
AppURL: "https://multica.example",
})
r.newSender = func(credentials) replySender { return sender }
return r