mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 18:39:17 +02:00
Compare commits
12 Commits
agent/j/ch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7f3cb9e3 | ||
|
|
b336f07617 | ||
|
|
10b33b14f5 | ||
|
|
9f1766cdb3 | ||
|
|
2b940046d7 | ||
|
|
f59cb2f494 | ||
|
|
d2bc85e01a | ||
|
|
63eb6f73ad | ||
|
|
c2e8892194 | ||
|
|
5206d7c613 | ||
|
|
e444698a09 | ||
|
|
658e63d9be |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-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": "キャンセル",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "リーダーボード",
|
||||
"caption": "{{count}} 件のエージェント",
|
||||
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
|
||||
"deleted_agents": "削除済みエージェント",
|
||||
"header_agent": "エージェント",
|
||||
"header_tokens": "トークン",
|
||||
"header_cost": "コスト",
|
||||
|
||||
@@ -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": "취소",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "리더보드",
|
||||
"caption": "에이전트 {{count}}개",
|
||||
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
|
||||
"deleted_agents": "삭제된 에이전트",
|
||||
"header_agent": "에이전트",
|
||||
"header_tokens": "토큰",
|
||||
"header_cost": "비용",
|
||||
|
||||
@@ -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 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": "需要的 Bot scopes:app_mentions:read、channels:history、chat:write、groups:history、im:history、mpim:history、users:read。",
|
||||
"byo_submit": "连接",
|
||||
"byo_submitting": "连接中…",
|
||||
"byo_cancel": "取消",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"caption": "{{count}} 个智能体",
|
||||
"caption_with_deleted": "{{count}} 个智能体 · {{deleted}} 个已删除",
|
||||
"deleted_agents": "已删除的智能体",
|
||||
"header_agent": "智能体",
|
||||
"header_tokens": "Token",
|
||||
"header_cost": "费用",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user