From 8fd4d9a3b1ccfba9f1d7a00786bb0d04e3e0118e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BB=95=E9=B9=8F=E9=A3=9E?= Date: Mon, 27 Jul 2026 11:57:50 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(dashboard):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=A4=96=E7=BD=AE=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=89=80=E9=9C=80?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=8E=E9=97=AE=E7=AD=94=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/ask-broker.ts | 45 +++++++ src/core/dashboard-rows.ts | 8 ++ src/core/session-row-enrichment.ts | 185 ++++++++++++++++++++++++++++ src/daemon.ts | 45 +++++++ src/dashboard.ts | 73 ++++++++++- test/session-row-enrichment.test.ts | 124 +++++++++++++++++++ 6 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 src/core/session-row-enrichment.ts create mode 100644 test/session-row-enrichment.test.ts diff --git a/src/core/ask-broker.ts b/src/core/ask-broker.ts index 837fefd8c..cd8404f91 100644 --- a/src/core/ask-broker.ts +++ b/src/core/ask-broker.ts @@ -441,6 +441,51 @@ export function getAskSnapshot(askId: string): PendingAsk | undefined { return a ? snapshot(a) : undefined; } +/** List unsettled asks for Desktop / dashboard aggregation (read-only snapshots). */ +export function listPendingAsks(): PendingAsk[] { + gcSettled(); + const out: PendingAsk[] = []; + for (const ask of pending.values()) { + if (!ask.settled) out.push(snapshot(ask)); + } + return out; +} + +/** + * Desktop / trusted-host answer path. Bypasses canTalk (no Feishu openId) — + * caller must be authenticated as the local dashboard/desktop operator. + */ +export function submitAskFromDesktop(args: { + askId: string; + /** Selected option keys per question (same shape as submitAsk selections). */ + selections: ReadonlyArray>; + by?: string; +}): AskClickOutcome { + gcSettled(); + const ask = pending.get(args.askId); + if (!ask) return 'stale'; + if (ask.settled) return 'already_settled'; + + const answers = args.selections; + for (let i = 0; i < ask.questions.length; i++) { + const q = ask.questions[i]!; + const sel = answers[i] ?? []; + if (!q.multiSelect && sel.length !== 1) return 'stale'; + for (const key of sel) { + if (!q.options.some((o) => o.key === key)) return 'stale'; + } + } + + settle(args.askId, { + kind: 'answered', + answers, + by: args.by ?? 'desktop', + comment: null, + timedOut: false, + }); + return 'accepted'; +} + /** Read a pending ask by id — for tests only. Returns a snapshot; mutating it * has no effect on broker state. */ export function _getPending(askId: string): PendingAsk | undefined { diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index c5d1d0312..d17db54b9 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -81,6 +81,14 @@ export interface SessionRow { /** Riff AIO Sandbox web terminal link. When set, the dashboard "Web终端" * button opens this URL directly instead of building a local port link. */ riffAccessUrl?: string; + /** Presentation enrichment stamped by the dashboard /api/sessions handler + * (see session-row-enrichment): bot avatar URL from bots-info.json. + * Absent on older daemons — consumers must fall back. */ + botAvatarUrl?: string; + /** Repo top-level dir name of workingDir, when it is a git repo. */ + repoName?: string; + /** Current branch of workingDir; absent for detached HEAD / non-repo. */ + gitBranch?: string; } export function feishuChatLink(chatId: string, brand: Brand = 'feishu'): string { diff --git a/src/core/session-row-enrichment.ts b/src/core/session-row-enrichment.ts new file mode 100644 index 000000000..f8c7d4d97 --- /dev/null +++ b/src/core/session-row-enrichment.ts @@ -0,0 +1,185 @@ +// src/core/session-row-enrichment.ts +// +// Presentation enrichment for GET /api/sessions rows: bot avatar (from the +// daemon's bots-info.json, mtime-cached) + git repo name/branch (resolved +// from workingDir, TTL-cached). Stamped in the dashboard handler so every +// consumer (board, Desktop sidebar) shares one additive row shape; rows from +// older daemons simply never carry the new fields. + +import { execFile } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +// ── Bot avatar map (bots-info.json, mtime-cached) ───────────────────────── + +type BotsInfoEntry = { + larkAppId?: string; + botAvatarUrl?: string | null; +}; + +let avatarCache: { path: string; mtimeMs: number; map: Map } | null = null; + +/** larkAppId → bot avatar URL from bots-info.json; empty map when unreadable. */ +export function getBotAvatarByAppId(dataDir: string): Map { + const path = join(dataDir, 'bots-info.json'); + let mtimeMs = -1; + try { + mtimeMs = statSync(path).mtimeMs; + } catch { + // File missing — cache the empty map keyed on mtime -1. + } + if (avatarCache && avatarCache.path === path && avatarCache.mtimeMs === mtimeMs) { + return avatarCache.map; + } + const map = new Map(); + if (mtimeMs >= 0) { + try { + const entries = JSON.parse(readFileSync(path, 'utf8')) as BotsInfoEntry[]; + if (Array.isArray(entries)) { + for (const e of entries) { + const appId = typeof e?.larkAppId === 'string' ? e.larkAppId : ''; + const url = typeof e?.botAvatarUrl === 'string' ? e.botAvatarUrl.trim() : ''; + if (appId && url) map.set(appId, url); + } + } + } catch { + /* unreadable → empty map */ + } + } + avatarCache = { path, mtimeMs, map }; + return map; +} + +// ── Git repo info (per-cwd TTL cache) ───────────────────────────────────── + +export type GitRepoInfo = { + /** basename of the repo top-level dir. */ + repoName: string; + /** Current branch; null for detached HEAD. */ + branch: string | null; +}; + +const GIT_INFO_OK_TTL_MS = 60_000; +const GIT_INFO_MISS_TTL_MS = 300_000; +const GIT_TIMEOUT_MS = 1_500; +/** Concurrent git probes across all callers (a 99-session first poll must not + * fork-bomb the host). */ +const GIT_MAX_CONCURRENT_PROBES = 8; + +const gitInfoCache = new Map(); +/** Dedup so a poll burst spawns at most one git probe per cwd. */ +const gitInfoInflight = new Map>(); + +let gitProbesRunning = 0; +const gitProbeQueue: Array<() => void> = []; + +async function withGitProbeSlot(fn: () => Promise): Promise { + if (gitProbesRunning >= GIT_MAX_CONCURRENT_PROBES) { + await new Promise((resolve) => gitProbeQueue.push(resolve)); + } + gitProbesRunning++; + try { + return await fn(); + } finally { + gitProbesRunning--; + gitProbeQueue.shift()?.(); + } +} + +function runGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + args, + { timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 }, + (err, stdout) => (err ? reject(err) : resolve(stdout)), + ); + }); +} + +async function probeGitRepoInfo(cwd: string): Promise { + // One probe: line 1 = top-level path, line 2 = branch ('HEAD' when detached). + const out = await runGit(['-C', cwd, 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD']); + const [top = '', branchRaw = ''] = out.split('\n').map((l) => l.trim()); + if (!top) return null; + return { + repoName: basename(top) || top, + branch: branchRaw && branchRaw !== 'HEAD' ? branchRaw : null, + }; +} + +/** Resolve repoName/branch for a session cwd; null when not a git repo. Never throws. */ +export async function getGitRepoInfo(cwd: string): Promise { + const dir = cwd.trim(); + if (!dir) return null; + const now = Date.now(); + const hit = gitInfoCache.get(dir); + if (hit && now - hit.at < (hit.info ? GIT_INFO_OK_TTL_MS : GIT_INFO_MISS_TTL_MS)) { + return hit.info; + } + const inflight = gitInfoInflight.get(dir); + if (inflight) return inflight; + const p = (async (): Promise => { + try { + return await withGitProbeSlot(() => probeGitRepoInfo(dir)); + } catch { + return null; + } + })(); + gitInfoInflight.set(dir, p); + try { + const info = await p; + gitInfoCache.set(dir, { at: Date.now(), info }); + return info; + } finally { + gitInfoInflight.delete(dir); + } +} + +// ── Row stamping ────────────────────────────────────────────────────────── + +type RowWithEnrichmentHints = { + larkAppId?: string; + workingDir?: string; +}; + +/** Test hook: clear both caches. */ +export function clearSessionRowEnrichmentCaches(): void { + avatarCache = null; + gitInfoCache.clear(); +} + +/** + * Stamp botAvatarUrl + repoName + gitBranch onto session rows. Best-effort + * and additive: avatars come from bots-info.json (sync, cheap); git info is + * probed per workingDir with a TTL cache. A total time budget keeps a wedged + * filesystem from stalling /api/sessions — late probes still land in the + * cache and show up on the next poll. + */ +export async function enrichSessionRowsForPresentation( + rows: readonly T[], + dataDir: string, +): Promise> { + const avatars = getBotAvatarByAppId(dataDir); + const withAvatar = rows.map((row) => { + const botAvatarUrl = row.larkAppId ? avatars.get(row.larkAppId) : undefined; + return botAvatarUrl ? { ...row, botAvatarUrl } : row; + }); + const withGit = withAvatar.map(async (row) => { + if (!row.workingDir) return row; + const git = await getGitRepoInfo(row.workingDir); + if (!git) return row; + return { + ...row, + ...(git.repoName ? { repoName: git.repoName } : {}), + ...(git.branch ? { gitBranch: git.branch } : {}), + }; + }); + const GIT_BUDGET_MS = 2_500; + return Promise.race([ + Promise.all(withGit), + new Promise((resolve) => + setTimeout(() => resolve(withAvatar), GIT_BUDGET_MS), + ), + ]); +} diff --git a/src/daemon.ts b/src/daemon.ts index 8c3df8666..6fb0ae2c0 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -266,6 +266,8 @@ import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, registerAsk as registerAskBroker, + listPendingAsks, + submitAskFromDesktop, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js'; @@ -4157,6 +4159,49 @@ ipcRoute('POST', '/api/asks', async (req, res) => { return jsonRes(res, 200, result); }); +// Desktop / dashboard: list pending ask-hooks for operator UI (not agent-facing). +ipcRoute('GET', '/api/asks/pending', (req, res) => { + if (!isTrustedHostIpcRequest(req)) { + return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); + } + const asks = listPendingAsks().map((a) => ({ + askId: a.askId, + sessionId: a.sessionId, + larkAppId: a.larkAppId, + chatId: a.chatId, + rootMessageId: a.rootMessageId, + questions: a.questions, + deadlineAt: a.deadlineAt, + createdAt: a.createdAt, + })); + return jsonRes(res, 200, { asks }); +}); + +// Desktop answers an ask (bypasses Feishu canTalk; trusted host only). +ipcRoute('POST', '/api/asks/answer', async (req, res) => { + if (!isTrustedHostIpcRequest(req)) { + return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); + } + let body: { askId?: string; selections?: string[][]; by?: string }; + try { + body = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + if (!body.askId || !Array.isArray(body.selections)) { + return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' }); + } + const outcome = submitAskFromDesktop({ + askId: body.askId, + selections: body.selections, + by: typeof body.by === 'string' ? body.by : 'desktop', + }); + if (outcome !== 'accepted') { + return jsonRes(res, 409, { ok: false, error: outcome }); + } + return jsonRes(res, 200, { ok: true, outcome }); +}); + // ─── attention IPC route (internal: set needs-you state) ───────────────────── // // NOT an agent-facing command. `botmux send --attention` posts the message diff --git a/src/dashboard.ts b/src/dashboard.ts index 030482bf5..ef20218af 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -54,6 +54,7 @@ import { hostLocalTimeZone, scheduleTimeZone } from './utils/timezone.js'; import { buildDashboardUrls, type DashboardUrls } from './core/dashboard-url.js'; import { resolveBotmuxDataDir } from './core/data-dir.js'; import { dashboardSecretPath } from './core/dashboard-secret.js'; +import { enrichSessionRowsForPresentation } from './core/session-row-enrichment.js'; import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js'; import { isLocalDevInstall, botmuxVersion, botmuxVersionAt, botmuxCliEntry, botmuxInstallRoot } from './utils/install-info.js'; import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js'; @@ -2399,7 +2400,77 @@ const server = createServer(async (req, res) => { ? { ...s, botName: n } : s; }); - return jsonRes(res, 200, { sessions }); + // Presentation enrichment (bot avatar + git repo/branch) — additive, + // cached, best-effort; Desktop sidebar renders these, board ignores them. + const enriched = await enrichSessionRowsForPresentation(sessions, config.session.dataDir); + return jsonRes(res, 200, { sessions: enriched }); + } + + // Desktop / operator UI: aggregate pending ask-hooks across daemons. + if (req.method === 'GET' && url.pathname === '/api/asks/pending') { + const daemons = registry.list(); + const asks: unknown[] = []; + await Promise.all(daemons.map(async (d) => { + try { + const upstream = await fetchDaemonIpc(d.ipcPort, '/api/asks/pending'); + if (!upstream.ok) return; + const body = await upstream.json() as { asks?: unknown[] }; + for (const a of body.asks ?? []) { + asks.push({ + ...(typeof a === 'object' && a ? a : {}), + botName: d.botName, + larkAppId: d.larkAppId, + }); + } + } catch { + /* offline daemon */ + } + })); + return jsonRes(res, 200, { asks }); + } + + if (req.method === 'POST' && url.pathname === '/api/asks/answer') { + let body: { askId?: string; selections?: string[][]; by?: string; larkAppId?: string }; + try { + body = await readJsonBody(req) as typeof body; + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + if (!body.askId || !Array.isArray(body.selections)) { + return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' }); + } + // Prefer explicit bot; else try each daemon until one accepts. + const candidates = body.larkAppId + ? registry.list().filter((d) => d.larkAppId === body.larkAppId) + : registry.list(); + for (const d of candidates) { + try { + const upstream = await proxyToDaemon(d.larkAppId, '/api/asks/answer', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + askId: body.askId, + selections: body.selections, + by: body.by ?? 'desktop', + }), + }); + const text = await upstream.text(); + if (upstream.ok) { + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(text); + return; + } + // 409 stale on wrong daemon — try next + if (upstream.status !== 409) { + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(text); + return; + } + } catch { + /* try next */ + } + } + return jsonRes(res, 409, { ok: false, error: 'stale' }); } if (req.method === 'POST' && url.pathname === '/api/sessions/cleanup-idle') { let body: { olderThanHours?: unknown; sessionIds?: unknown }; diff --git a/test/session-row-enrichment.test.ts b/test/session-row-enrichment.test.ts new file mode 100644 index 000000000..614b42702 --- /dev/null +++ b/test/session-row-enrichment.test.ts @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + clearSessionRowEnrichmentCaches, + enrichSessionRowsForPresentation, + getBotAvatarByAppId, + getGitRepoInfo, +} from '../src/core/session-row-enrichment.js'; + +let dirs: string[] = []; + +function tempDir(prefix: string): string { + const d = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(d); + return d; +} + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +function initRepo(branch: string): string { + const dir = tempDir('botmux-enrich-repo-'); + git(['init', '-q'], dir); + git(['checkout', '-q', '-b', branch], dir); + git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'x'], dir); + return dir; +} + +beforeEach(() => clearSessionRowEnrichmentCaches()); +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe('getBotAvatarByAppId', () => { + it('maps larkAppId → avatar URL from bots-info.json', () => { + const dataDir = tempDir('botmux-enrich-data-'); + writeFileSync( + join(dataDir, 'bots-info.json'), + JSON.stringify([ + { larkAppId: 'cli_a', botAvatarUrl: 'https://img.example/a.png' }, + { larkAppId: 'cli_b', botAvatarUrl: null }, + { larkAppId: 'cli_c' }, + ]), + ); + const map = getBotAvatarByAppId(dataDir); + expect(map.get('cli_a')).toBe('https://img.example/a.png'); + expect(map.has('cli_b')).toBe(false); + expect(map.has('cli_c')).toBe(false); + }); + + it('returns an empty map when bots-info.json is missing or corrupt', () => { + const dataDir = tempDir('botmux-enrich-data-'); + expect(getBotAvatarByAppId(dataDir).size).toBe(0); + writeFileSync(join(dataDir, 'bots-info.json'), '{nope'); + expect(getBotAvatarByAppId(dataDir).size).toBe(0); + }); +}); + +describe('getGitRepoInfo', () => { + it('resolves repo name + branch for a git workdir', async () => { + const repo = initRepo('feat/enrich-x'); + const info = await getGitRepoInfo(repo); + expect(info?.repoName).toBe(repo.split('/').pop()); + expect(info?.branch).toBe('feat/enrich-x'); + }); + + it('resolves from a subdirectory of the repo', async () => { + const repo = initRepo('main'); + const sub = join(repo, 'a/b'); + mkdirSync(sub, { recursive: true }); + const info = await getGitRepoInfo(sub); + expect(info?.repoName).toBe(repo.split('/').pop()); + expect(info?.branch).toBe('main'); + }); + + it('returns null branch for detached HEAD', async () => { + const repo = initRepo('main'); + git(['checkout', '-q', '--detach', 'HEAD'], repo); + const info = await getGitRepoInfo(repo); + expect(info?.repoName).toBeTruthy(); + expect(info?.branch).toBeNull(); + }); + + it('returns null for non-repo dirs and caches the miss', async () => { + const plain = tempDir('botmux-enrich-plain-'); + expect(await getGitRepoInfo(plain)).toBeNull(); + // Second call must be served from cache (no throw, still null). + expect(await getGitRepoInfo(plain)).toBeNull(); + }); + + it('returns null for empty/missing cwd without spawning git', async () => { + expect(await getGitRepoInfo('')).toBeNull(); + expect(await getGitRepoInfo(' ')).toBeNull(); + }); +}); + +describe('enrichSessionRowsForPresentation', () => { + it('stamps avatar + repo/branch; passes untouched rows through by identity', async () => { + const dataDir = tempDir('botmux-enrich-data-'); + writeFileSync( + join(dataDir, 'bots-info.json'), + JSON.stringify([{ larkAppId: 'cli_a', botAvatarUrl: 'https://img.example/a.png' }]), + ); + const repo = initRepo('main'); + const plain = tempDir('botmux-enrich-plain-'); + + const rich = { sessionId: 's1', larkAppId: 'cli_a', workingDir: repo }; + const plainRow = { sessionId: 's2', larkAppId: 'cli_zzz', workingDir: plain }; + const bare = { sessionId: 's3' }; + const [r1, r2, r3] = await enrichSessionRowsForPresentation([rich, plainRow, bare], dataDir); + + expect(r1.botAvatarUrl).toBe('https://img.example/a.png'); + expect(r1.repoName).toBe(repo.split('/').pop()); + expect(r1.gitBranch).toBe('main'); + expect(r2.botAvatarUrl).toBeUndefined(); + expect(r2.repoName).toBeUndefined(); + expect(r2).toBe(plainRow); + expect(r3).toBe(bare); + }); +}); From ff39283155b4bb077d0574d38010fa16f98275b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BB=95=E9=B9=8F=E9=A3=9E?= Date: Mon, 27 Jul 2026 11:58:14 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(dashboard):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=A4=96=E7=BD=AE=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dashboard/compat.ts | 141 +++++++++++++++++++- src/dashboard/web/app.tsx | 22 ++- src/dashboard/web/client-shell.ts | 80 +++++++++++ src/dashboard/web/sessions-kanban.tsx | 6 +- src/dashboard/web/sessions-page.tsx | 23 ++-- test/dashboard-sessions-ui.test.ts | 25 ++++ test/desktop/dashboard-client-shell.test.ts | 70 ++++++++++ test/desktop/dashboard-compat.test.ts | 73 +++++++++- 8 files changed, 413 insertions(+), 27 deletions(-) create mode 100644 src/dashboard/web/client-shell.ts create mode 100644 test/desktop/dashboard-client-shell.test.ts diff --git a/src/dashboard/compat.ts b/src/dashboard/compat.ts index 0b9ab32a4..fcd922bed 100644 --- a/src/dashboard/compat.ts +++ b/src/dashboard/compat.ts @@ -1,41 +1,164 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { readPlatformBinding } from '../platform/binding.js'; import { botmuxInstallRoot, botmuxVersion } from '../utils/install-info.js'; import { resolveEffectiveBotmuxVersion } from '../utils/version-info.js'; import { jsonRes } from './http.js'; +export interface DashboardCompatModule { + supported: boolean; + route?: string; +} + +export type DashboardCompatCapability = + | 'asks.answer' + | 'asks.read' + | 'bots.configure' + | 'bots.read' + | 'connectors.manage' + | 'connectors.read' + | 'groups.manage' + | 'groups.read' + | 'insights.read' + | 'monitoring.read' + | 'office.read' + | 'plugins.manage' + | 'plugins.read' + | 'roles.manage' + | 'roles.read' + | 'schedules.manage' + | 'schedules.read' + | 'sessions.manage' + | 'sessions.read' + | 'sessions.terminal' + | 'settings.manage' + | 'settings.read' + | 'skills.manage' + | 'skills.read' + | 'team.manage' + | 'team.read' + | 'updates.manage' + | 'updates.read' + | 'whiteboards.manage' + | 'whiteboards.read' + | 'workflow.manage' + | 'workflow.read'; + export interface DesktopCompatManifest { schemaVersion: 1; product: 'botmux'; runtimeVersion: string; - dashboardProtocolVersion: 1; + dashboardProtocolVersion: 2; desktopShell: { supported: true; minAppVersion?: string; }; + runtimeIdentity?: { + source: 'platform-binding'; + machineId: string; + }; features: string[]; routes: string[]; + modules: Record; + capabilities: Record; } export interface BuildCompatManifestOptions { runtimeVersion?: string; + /** + * Override the machine identity used by tests or an embedding host. + * `null` explicitly means that no reliable machine identity is available. + */ + machineId?: string | null; } const DASHBOARD_CORE_ROUTES = [ '#/', '#/sessions', - '#/workflows', '#/groups', + '#/roles', + '#/monitoring', + '#/insights', '#/schedules', + '#/whiteboards', + '#/office', + '#/bot-defaults', + '#/skills', + '#/plugins', + '#/team', + '#/connectors', '#/settings', ] as const; const DASHBOARD_COMPAT_FEATURES = [ 'desktop-shell', 'dashboard-protocol-v1', + 'dashboard-protocol-v2', + 'dashboard-modules', + 'dashboard-capabilities', ] as const; +const DASHBOARD_MODULES = { + overview: { supported: true, route: '#/' }, + sessions: { supported: true, route: '#/sessions' }, + groups: { supported: true, route: '#/groups' }, + roles: { supported: true, route: '#/roles' }, + monitoring: { supported: true, route: '#/monitoring' }, + insights: { supported: true, route: '#/insights' }, + schedules: { supported: true, route: '#/schedules' }, + whiteboards: { supported: true, route: '#/whiteboards' }, + office: { supported: true, route: '#/office' }, + bots: { supported: true, route: '#/bot-defaults' }, + skills: { supported: true, route: '#/skills' }, + plugins: { supported: true, route: '#/plugins' }, + team: { supported: true, route: '#/team' }, + connectors: { supported: true, route: '#/connectors' }, + settings: { supported: true, route: '#/settings' }, + workflow: { supported: false }, +} satisfies Record; + +const DASHBOARD_CAPABILITIES: Record = { + 'asks.answer': true, + 'asks.read': true, + 'bots.configure': true, + 'bots.read': true, + 'connectors.manage': true, + 'connectors.read': true, + 'groups.manage': true, + 'groups.read': true, + 'insights.read': true, + 'monitoring.read': true, + 'office.read': true, + 'plugins.manage': true, + 'plugins.read': true, + 'roles.manage': true, + 'roles.read': true, + 'schedules.manage': true, + 'schedules.read': true, + 'sessions.manage': true, + 'sessions.read': true, + 'sessions.terminal': true, + 'settings.manage': true, + 'settings.read': true, + 'skills.manage': true, + 'skills.read': true, + 'team.manage': true, + 'team.read': true, + 'updates.manage': true, + 'updates.read': true, + 'whiteboards.manage': true, + 'whiteboards.read': true, + 'workflow.manage': false, + 'workflow.read': false, +}; + export function buildCompatManifest(options: BuildCompatManifestOptions = {}): DesktopCompatManifest { + const machineId = normalizeMachineId( + options.machineId === undefined + ? readPlatformBinding()?.machineId + : options.machineId, + ); + return { schemaVersion: 1, product: 'botmux', @@ -43,15 +166,23 @@ export function buildCompatManifest(options: BuildCompatManifestOptions = {}): D rawVersion: botmuxVersion(), rootDir: botmuxInstallRoot(), }), - dashboardProtocolVersion: 1, - // Keep this v1 manifest static: the desktop shell uses it as a cheap - // compatibility probe before loading the full dashboard UI. + dashboardProtocolVersion: 2, desktopShell: { supported: true }, + ...(machineId + ? { runtimeIdentity: { source: 'platform-binding' as const, machineId } } + : {}), features: [...DASHBOARD_COMPAT_FEATURES], routes: [...DASHBOARD_CORE_ROUTES], + modules: structuredClone(DASHBOARD_MODULES), + capabilities: { ...DASHBOARD_CAPABILITIES }, }; } +function normalizeMachineId(value: string | null | undefined): string | undefined { + const machineId = value?.trim(); + return machineId ? machineId : undefined; +} + export function handleDesktopCompat(req: IncomingMessage, res: ServerResponse, url: URL): boolean { if (req.method !== 'GET' || url.pathname !== '/__desktop/compat') return false; jsonRes(res, 200, buildCompatManifest()); diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx index afbe97372..77b6871f4 100644 --- a/src/dashboard/web/app.tsx +++ b/src/dashboard/web/app.tsx @@ -29,6 +29,10 @@ import { InfoTip } from './dashboard-components.js'; import { initFloatingScrollbars } from './floating-scrollbars.js'; import { PLUGIN_PINS_CHANGED_EVENT } from './plugin-events.js'; import { updateAndRestartBotmux, type BotmuxUpdatePhase } from './update-action.js'; +import { + dashboardClientShellRedirect, + readDashboardClientShell, +} from './client-shell.js'; type OwnerAvatar = { avatarUrl: string; name?: string }; type TopbarAttentionNotice = { count: number; time: string; bot: string; reason: string }; @@ -193,13 +197,16 @@ function isActiveNav(item: NavItem, hash: string): boolean { } function sidebarNavItems(): NavItem[] { - if (pinnedPluginNavItems.length === 0) return NAV_ITEMS; - const pluginIndex = NAV_ITEMS.findIndex(item => item.id === 'plugins'); - if (pluginIndex < 0) return [...NAV_ITEMS, ...pinnedPluginNavItems]; + const builtInItems = readDashboardClientShell() + ? NAV_ITEMS.filter(item => item.id !== 'workflows') + : NAV_ITEMS; + if (pinnedPluginNavItems.length === 0) return builtInItems; + const pluginIndex = builtInItems.findIndex(item => item.id === 'plugins'); + if (pluginIndex < 0) return [...builtInItems, ...pinnedPluginNavItems]; return [ - ...NAV_ITEMS.slice(0, pluginIndex + 1), + ...builtInItems.slice(0, pluginIndex + 1), ...pinnedPluginNavItems, - ...NAV_ITEMS.slice(pluginIndex + 1), + ...builtInItems.slice(pluginIndex + 1), ]; } @@ -1270,6 +1277,11 @@ async function route(): Promise { activeHash = hash; renderShell(); + const clientShellRedirect = dashboardClientShellRedirect(hash); + if (clientShellRedirect) { + window.location.replace(clientShellRedirect); + return; + } if (!isAuthed && MANAGE_ROUTES.some(r => hash.startsWith('#/' + r))) { renderAuthRequiredPage(getRouteRoot()); routeState.rerenderOnUiChange = true; diff --git a/src/dashboard/web/client-shell.ts b/src/dashboard/web/client-shell.ts new file mode 100644 index 000000000..318ed04ad --- /dev/null +++ b/src/dashboard/web/client-shell.ts @@ -0,0 +1,80 @@ +export type DashboardClientShell = 'desktop' | 'mobile'; + +const CLIENT_SHELL_PARAM = 'botmuxClientShell'; +const CLIENT_SHELLS = new Set(['desktop', 'mobile']); + +function normalizeClientShell(value: string | null): DashboardClientShell | null { + return value && CLIENT_SHELLS.has(value as DashboardClientShell) + ? value as DashboardClientShell + : null; +} + +/** + * Detect the restricted Desktop/Mobile dashboard shell. + * + * The query-string form is canonical because hash navigation must not clear + * the shell boundary. Reading the hash form as a compatibility fallback lets + * old one-time open links reach the shell long enough to be redirected. + */ +export function readDashboardClientShell( + search = typeof location === 'undefined' ? '' : location.search, + hash = typeof location === 'undefined' ? '' : location.hash, +): DashboardClientShell | null { + const fromSearch = normalizeClientShell( + new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + .get(CLIENT_SHELL_PARAM), + ); + if (fromSearch) return fromSearch; + + const queryIndex = hash.indexOf('?'); + if (queryIndex < 0) return null; + return normalizeClientShell( + new URLSearchParams(hash.slice(queryIndex + 1)).get(CLIENT_SHELL_PARAM), + ); +} + +/** Workflow is deliberately outside the Botmux Desktop/Mobile integration. */ +export function isWorkflowDashboardHash(hash: string): boolean { + const path = (hash.split('?')[0] || '#/').toLowerCase(); + return ( + path === '#/workflows' || + path.startsWith('#/workflows/') || + path.startsWith('#/workflows-') || + path === '#/v3' || + path.startsWith('#/v3/') || + path.startsWith('#/v3?') || + path === '#/legacy-workflow' || + path.startsWith('#/legacy-workflow/') + ); +} + +/** Monitor Room is a web-terminal surface and is owned by the native client. */ +export function isWebTerminalDashboardHash(hash: string): boolean { + const path = (hash.split('?')[0] || '#/').toLowerCase(); + return path === '#/monitor-room' || path.startsWith('#/monitor-room/'); +} + +/** + * Embedded Desktop/Mobile dashboards must never offer web terminal actions. + * + * The native Botmux Sessions surface owns terminal attachment in those clients; + * keeping this decision beside the shell parser prevents a future UI control + * from accidentally minting a write link that the Electron boundary rejects. + */ +export function dashboardShellAllowsWebTerminal( + search = typeof location === 'undefined' ? '' : location.search, + hash = typeof location === 'undefined' ? '' : location.hash, +): boolean { + return readDashboardClientShell(search, hash) === null; +} + +/** Resolve unsupported embedded routes before the lazy page module is loaded. */ +export function dashboardClientShellRedirect( + hash: string, + search = typeof location === 'undefined' ? '' : location.search, +): '#/' | '#/sessions' | null { + if (!readDashboardClientShell(search, hash)) return null; + if (isWorkflowDashboardHash(hash)) return '#/'; + if (isWebTerminalDashboardHash(hash)) return '#/sessions'; + return null; +} diff --git a/src/dashboard/web/sessions-kanban.tsx b/src/dashboard/web/sessions-kanban.tsx index 10b3c6b41..8e225e369 100644 --- a/src/dashboard/web/sessions-kanban.tsx +++ b/src/dashboard/web/sessions-kanban.tsx @@ -78,7 +78,7 @@ export interface SessionsKanbanCallbacks { onMoveRows: (moves: SessionsKanbanMove[]) => void; onNeedTeamBoard: (team: SessionsKanbanTeam) => void; onNeedTeams: () => void; - onOpenTerminal: (row: any) => void; + onOpenTerminal?: (row: any) => void; onRename: (row: any, title: string) => void; onRestart: (row: any, button: HTMLButtonElement) => void; onTeamScope: (scope: { chats: number; sessions: number } | null) => void; @@ -427,12 +427,12 @@ function KanbanCard(props: { label={t('sessions.history.title')} onClick={() => callbacks.onHistory(row)} /> - {row.webPort ? ( + {row.webPort && callbacks.onOpenTerminal ? ( callbacks.onOpenTerminal(row)} + onClick={() => callbacks.onOpenTerminal?.(row)} /> ) : null} {row.feishuChatLink ? ( diff --git a/src/dashboard/web/sessions-page.tsx b/src/dashboard/web/sessions-page.tsx index 2fc43331e..05901ff26 100644 --- a/src/dashboard/web/sessions-page.tsx +++ b/src/dashboard/web/sessions-page.tsx @@ -70,6 +70,7 @@ import { type SessionTopicGroup, } from './sessions.js'; import { addMonitorRoomSessionIds, monitorRoomUrl } from './monitor-room-store.js'; +import { dashboardShellAllowsWebTerminal } from './client-shell.js'; import { CreateActionButton, DropdownMenu, LoadingState } from './dashboard-components.js'; import { filterMentionBots, @@ -237,7 +238,7 @@ function IconActionButton(props: { } function TerminalControls(props: { row: any; url: string | null }): JSX.Element | null { - if (!props.url) return null; + if (!props.url || !dashboardShellAllowsWebTerminal()) return null; const readOnly = !shouldOpenWritableTerminal(); return ( @@ -624,12 +625,15 @@ function BulkBar(props: { const busy = !!props.closeProgress || !!props.lockProgress; const lockText = props.lockProgress?.locked ? `${props.lockProgress.done}/${props.lockProgress.total}` : t('sessions.lockSelected'); const unlockText = props.lockProgress && !props.lockProgress.locked ? `${props.lockProgress.done}/${props.lockProgress.total}` : t('sessions.unlockSelected'); + const webTerminalAvailable = dashboardShellAllowsWebTerminal(); return (
- + {dashboardShellAllowsWebTerminal() ? ( + + ) : null} {ui.authed ? ( { void ensureTeamBoard(team); }} onNeedTeams={() => { void loadKanbanTeams(); }} - onOpenTerminal={openTerminalModal} + onOpenTerminal={dashboardShellAllowsWebTerminal() ? openTerminalModal : undefined} onRename={(row, title) => { const s = store.sessions.get(String(row.sessionId)); if (s) void persistRename(s, title); }} onRestart={(row, button) => { const s = store.sessions.get(String(row.sessionId)); if (s) void restartSession(s, button); }} onTeamScope={scope => setTeamScopeText(scope ? t('sessions.kanban.teamScope', { chats: scope.chats, sessions: scope.sessions }) : '')} diff --git a/test/dashboard-sessions-ui.test.ts b/test/dashboard-sessions-ui.test.ts index 1cb3974b3..a12f28144 100644 --- a/test/dashboard-sessions-ui.test.ts +++ b/test/dashboard-sessions-ui.test.ts @@ -400,4 +400,29 @@ describe('dashboard sessions kanban react view', () => { expect((html.match(/data-id="closed-/g) ?? []).length).toBe(50); expect(html).toContain('还有 5 个未显示'); }); + + it('omits the terminal action when the embedding shell does not provide it', () => { + const html = renderToStaticMarkup(createElement(SessionsKanbanView, { + host: null, + ...kanbanCallbacks, + onOpenTerminal: undefined, + rows: [{ + sessionId: 'embedded-session', + status: 'working', + cliId: 'codex', + title: 'Embedded', + botName: 'Bot A', + lastMessageAt: 1000, + webPort: 3001, + }], + groupBy: 'flow', + teams: [], + teamsLoaded: true, + teamKey: '', + teamBoardData: null, + teamBoardKey: '', + })); + + expect(html).not.toContain('data-action="terminal"'); + }); }); diff --git a/test/desktop/dashboard-client-shell.test.ts b/test/desktop/dashboard-client-shell.test.ts new file mode 100644 index 000000000..e7a2454ad --- /dev/null +++ b/test/desktop/dashboard-client-shell.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + dashboardClientShellRedirect, + dashboardShellAllowsWebTerminal, + isWebTerminalDashboardHash, + isWorkflowDashboardHash, + readDashboardClientShell, +} from '../../src/dashboard/web/client-shell.js'; + +describe('dashboard client shell', () => { + it('reads the durable query-string shell marker', () => { + expect(readDashboardClientShell('?botmuxClientShell=desktop', '#/settings')) + .toBe('desktop'); + expect(readDashboardClientShell('?botmuxClientShell=mobile', '#/sessions')) + .toBe('mobile'); + }); + + it('accepts the hash marker only as a compatibility fallback', () => { + expect(readDashboardClientShell('', '#/sessions?botmuxClientShell=mobile')) + .toBe('mobile'); + expect(readDashboardClientShell('?botmuxClientShell=unknown', '#/')) + .toBeNull(); + }); + + it('recognizes every legacy and current workflow route', () => { + for (const hash of [ + '#/workflows', + '#/workflows/run-1', + '#/workflows-catalog', + '#/v3', + '#/v3/run-1', + '#/legacy-workflow', + '#/legacy-workflow/run-1', + ]) { + expect(isWorkflowDashboardHash(hash), hash).toBe(true); + } + expect(isWorkflowDashboardHash('#/sessions')).toBe(false); + expect(isWorkflowDashboardHash('#/settings?tab=runtime')).toBe(false); + }); + + it('recognizes Monitor Room as a web-terminal surface', () => { + expect(isWebTerminalDashboardHash('#/monitor-room')).toBe(true); + expect(isWebTerminalDashboardHash('#/monitor-room/session-1?focus=1')).toBe(true); + expect(isWebTerminalDashboardHash('#/sessions')).toBe(false); + }); + + it('disables web terminals for both embedded client shells', () => { + expect(dashboardShellAllowsWebTerminal('', '#/sessions')).toBe(true); + expect(dashboardShellAllowsWebTerminal('?botmuxClientShell=desktop', '#/sessions')).toBe(false); + expect(dashboardShellAllowsWebTerminal('?botmuxClientShell=mobile', '#/sessions')).toBe(false); + expect(dashboardShellAllowsWebTerminal('', '#/sessions?botmuxClientShell=desktop')).toBe(false); + }); + + it('redirects unsupported embedded routes before they can render', () => { + expect(dashboardClientShellRedirect( + '#/monitor-room', + '?botmuxClientShell=desktop', + )).toBe('#/sessions'); + expect(dashboardClientShellRedirect( + '#/workflows/run-1', + '?botmuxClientShell=mobile', + )).toBe('#/'); + expect(dashboardClientShellRedirect( + '#/sessions', + '?botmuxClientShell=desktop', + )).toBeNull(); + expect(dashboardClientShellRedirect('#/monitor-room', '')).toBeNull(); + }); +}); diff --git a/test/desktop/dashboard-compat.test.ts b/test/desktop/dashboard-compat.test.ts index bbc03113a..75123f609 100644 --- a/test/desktop/dashboard-compat.test.ts +++ b/test/desktop/dashboard-compat.test.ts @@ -15,16 +15,70 @@ afterEach(async () => { }); describe('dashboard desktop compat manifest', () => { - it('builds the stable v1 manifest shape', () => { - expect(buildCompatManifest({ runtimeVersion: '2.95.0' })).toEqual({ + it('builds the v2 manifest while retaining the v1 compatibility fields', () => { + const manifest = buildCompatManifest({ + runtimeVersion: '2.95.0', + machineId: null, + }); + + expect(manifest).toMatchObject({ schemaVersion: 1, product: 'botmux', runtimeVersion: '2.95.0', - dashboardProtocolVersion: 1, + dashboardProtocolVersion: 2, desktopShell: { supported: true }, - features: ['desktop-shell', 'dashboard-protocol-v1'], - routes: ['#/', '#/sessions', '#/workflows', '#/groups', '#/schedules', '#/settings'], + features: expect.arrayContaining([ + 'desktop-shell', + 'dashboard-protocol-v1', + 'dashboard-protocol-v2', + 'dashboard-modules', + 'dashboard-capabilities', + ]), + routes: expect.arrayContaining(['#/', '#/sessions', '#/groups', '#/schedules', '#/settings']), + }); + expect(manifest.runtimeIdentity).toBeUndefined(); + }); + + it('advertises granular dashboard modules and excludes workflow', () => { + const manifest = buildCompatManifest({ runtimeVersion: '2.95.0', machineId: null }); + + expect(manifest.modules).toMatchObject({ + overview: { supported: true, route: '#/' }, + sessions: { supported: true, route: '#/sessions' }, + monitoring: { supported: true, route: '#/monitoring' }, + insights: { supported: true, route: '#/insights' }, + bots: { supported: true, route: '#/bot-defaults' }, + schedules: { supported: true, route: '#/schedules' }, + settings: { supported: true, route: '#/settings' }, + workflow: { supported: false }, }); + expect(manifest.capabilities).toMatchObject({ + 'sessions.read': true, + 'sessions.manage': true, + 'asks.answer': true, + 'monitoring.read': true, + 'bots.configure': true, + 'schedules.manage': true, + 'settings.manage': true, + 'workflow.read': false, + 'workflow.manage': false, + }); + expect(manifest.routes).not.toContain('#/workflows'); + }); + + it('only exposes a machine identity when a reliable id is supplied', () => { + expect(buildCompatManifest({ + runtimeVersion: '2.95.0', + machineId: ' machine-123 ', + }).runtimeIdentity).toEqual({ + source: 'platform-binding', + machineId: 'machine-123', + }); + + expect(buildCompatManifest({ + runtimeVersion: '2.95.0', + machineId: ' ', + }).runtimeIdentity).toBeUndefined(); }); it('serves GET /__desktop/compat as read-only JSON', async () => { @@ -36,8 +90,15 @@ describe('dashboard desktop compat manifest', () => { expect(await compat.json()).toMatchObject({ schemaVersion: 1, product: 'botmux', - dashboardProtocolVersion: 1, + dashboardProtocolVersion: 2, desktopShell: { supported: true }, + modules: { + workflow: { supported: false }, + }, + capabilities: { + 'workflow.read': false, + 'workflow.manage': false, + }, }); const post = await fetch(`${started.baseUrl}/__desktop/compat`, { method: 'POST' }); From 169f5253aa5474b994d59842abb8ec91a3dc3c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BB=95=E9=B9=8F=E9=A3=9E?= Date: Mon, 27 Jul 2026 11:58:14 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(terminal):=20=E6=81=A2=E5=A4=8D=20tmux?= =?UTF-8?q?=20=E7=AA=97=E5=8F=A3=E8=87=AA=E9=80=82=E5=BA=94=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worker.ts | 5 +++++ test/web-terminal-tmux-window-size.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 test/web-terminal-tmux-window-size.test.ts diff --git a/src/worker.ts b/src/worker.ts index ca480fcd9..dca87ec3f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -7815,6 +7815,11 @@ function startWebServer(host: string, preferredPort?: number): Promise { const startAttach = (cols: number, rows: number) => { if (cp) return; + // Why: a prior web resize may leave the shared tmux window in manual mode. + spawnSync('tmux', ['set-option', '-t', tmuxTarget, 'window-size', 'latest'], { + stdio: 'ignore', + env: tmuxEnv() as { [key: string]: string }, + }); // Defense in depth: view-capability clients attach through tmux's // own read-only mode as well as the WebSocket input gate below. cp = pty.spawn('tmux', [ diff --git a/test/web-terminal-tmux-window-size.test.ts b/test/web-terminal-tmux-window-size.test.ts new file mode 100644 index 000000000..9dea2ce54 --- /dev/null +++ b/test/web-terminal-tmux-window-size.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const workerSource = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8') + +describe('web terminal tmux attach sizing', () => { + it('restores responsive tmux sizing before spawning the phone-sized attach', () => { + const attachBlockStart = workerSource.indexOf('const startAttach = (cols: number, rows: number)') + const attachBlockEnd = workerSource.indexOf("cp = pty.spawn('tmux'", attachBlockStart) + expect(attachBlockStart).toBeGreaterThan(-1) + expect(attachBlockEnd).toBeGreaterThan(attachBlockStart) + + const beforeSpawn = workerSource.slice(attachBlockStart, attachBlockEnd) + expect(beforeSpawn).toContain("['set-option', '-t', tmuxTarget, 'window-size', 'latest']") + }) +}) From 3d6935faad82b52e6c88854cad713f86f5023cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BB=95=E9=B9=8F=E9=A3=9E?= Date: Mon, 27 Jul 2026 21:18:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(dashboard):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A4=96=E7=BD=AE=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/dashboard-ipc-server.ts | 44 +++++ src/core/dashboard-rows.ts | 4 +- src/core/session-cwd.ts | 8 + src/core/session-row-enrichment.ts | 131 ++++--------- src/daemon.ts | 45 ----- src/dashboard.ts | 117 +++++++----- src/dashboard/aggregator.ts | 41 +++- src/dashboard/compat.ts | 201 +++++++++----------- src/dashboard/desktop-asks.ts | 74 +++++++ src/dashboard/public-redact.ts | 33 ++++ src/dashboard/session-presentation.ts | 87 +++++++++ src/dashboard/web/app.tsx | 5 + src/dashboard/web/client-shell.ts | 26 +++ src/dashboard/web/store.ts | 103 ++++++++-- src/worker.ts | 5 +- test/dashboard-aggregator.test.ts | 59 ++++++ test/dashboard-desktop-asks.test.ts | 64 +++++++ test/dashboard-ipc.test.ts | 107 +++++++++++ test/dashboard-public-redact.test.ts | 42 +++- test/dashboard-session-presentation.test.ts | 162 ++++++++++++++++ test/dashboard-store-bootstrap.test.ts | 131 +++++++++++++ test/desktop/dashboard-client-shell.test.ts | 20 ++ test/desktop/dashboard-compat.test.ts | 36 +++- test/session-cwd.test.ts | 64 +++++++ test/session-row-enrichment.test.ts | 64 ++----- test/web-terminal-tmux-window-size.test.ts | 16 -- 26 files changed, 1287 insertions(+), 402 deletions(-) create mode 100644 src/dashboard/desktop-asks.ts create mode 100644 src/dashboard/session-presentation.ts create mode 100644 test/dashboard-desktop-asks.test.ts create mode 100644 test/dashboard-session-presentation.test.ts create mode 100644 test/dashboard-store-bootstrap.test.ts create mode 100644 test/session-cwd.test.ts delete mode 100644 test/web-terminal-tmux-window-size.test.ts diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 16e882dad..bf3b62d3d 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -92,6 +92,7 @@ import { validateTriggerRequest, type TriggerResponse } from '../services/trigge import { resolveCliSelection, selectionKeyForBot } from '../setup/cli-selection.js'; import { checkCliAvailability } from '../setup/cli-availability.js'; import { enrichHistorySenders, type HistoryBotInfo } from '../dashboard/history-senders.js'; +import { listPendingAsks, submitAskFromDesktop } from './ask-broker.js'; // 机器人真·改名 renamer,由 daemon 启动时注册(开放平台自动化 + daemon 侧 // botName/descriptor/bots-info 同步都在 daemon 的闭包里做)。未注册(测试环境) @@ -340,6 +341,49 @@ ipcRoute('POST', DEVICE_ISOLATION_COMMIT_PATH, (req, res) => ipcRoute('POST', DEVICE_ISOLATION_RELEASE_PATH, (req, res) => handleDeviceIsolationActivationRoute(req, res, releaseDeviceIsolationActivation)); +// ─── Pending asks (trusted Desktop/dashboard operator only) ───────────────── + +ipcRoute('GET', '/api/asks/pending', (req, res) => { + if (!isTrustedHostIpcRequest(req)) { + return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); + } + const asks = listPendingAsks().map((ask) => ({ + askId: ask.askId, + sessionId: ask.sessionId, + larkAppId: ask.larkAppId, + chatId: ask.chatId, + rootMessageId: ask.rootMessageId, + questions: ask.questions, + deadlineAt: ask.deadlineAt, + createdAt: ask.createdAt, + })); + return jsonRes(res, 200, { asks }); +}); + +ipcRoute('POST', '/api/asks/answer', async (req, res) => { + if (!isTrustedHostIpcRequest(req)) { + return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); + } + let body: { askId?: string; selections?: string[][]; by?: string }; + try { + body = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + if (!body.askId || !Array.isArray(body.selections)) { + return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' }); + } + const outcome = submitAskFromDesktop({ + askId: body.askId, + selections: body.selections, + by: typeof body.by === 'string' ? body.by : 'desktop', + }); + if (outcome !== 'accepted') { + return jsonRes(res, 409, { ok: false, error: outcome }); + } + return jsonRes(res, 200, { ok: true, outcome }); +}); + ipcRoute('GET', '/api/sessions', (_req, res) => { // Active first (live state), closed appended (historical) const active = listActiveSessions().map(composeRowFromActive); diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index d17db54b9..f0e45ab6d 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -81,8 +81,8 @@ export interface SessionRow { /** Riff AIO Sandbox web terminal link. When set, the dashboard "Web终端" * button opens this URL directly instead of building a local port link. */ riffAccessUrl?: string; - /** Presentation enrichment stamped by the dashboard /api/sessions handler - * (see session-row-enrichment): bot avatar URL from bots-info.json. + /** Presentation enrichment stamped by the central dashboard read-model: + * bot avatar URL from the live daemon descriptor. * Absent on older daemons — consumers must fall back. */ botAvatarUrl?: string; /** Repo top-level dir name of workingDir, when it is a git repo. */ diff --git a/src/core/session-cwd.ts b/src/core/session-cwd.ts index a3d5126ae..cd57d3d22 100644 --- a/src/core/session-cwd.ts +++ b/src/core/session-cwd.ts @@ -5,6 +5,7 @@ */ import type { DaemonSession } from './types.js'; import * as sessionStore from '../services/session-store.js'; +import { dashboardEventBus } from './dashboard-events.js'; /** * 重钉一个话题会话的工作目录(daemon 记录 = 唯一事实源): @@ -20,4 +21,11 @@ export function repinSessionWorkingDir(ds: DaemonSession, resolvedPath: string): // 两条改 cwd 的路径都必须清。 ds.session.riffRepoDirs = undefined; sessionStore.updateSession(ds.session); + dashboardEventBus.publish({ + type: 'session.update', + body: { + sessionId: ds.session.sessionId, + patch: { workingDir: resolvedPath }, + }, + }); } diff --git a/src/core/session-row-enrichment.ts b/src/core/session-row-enrichment.ts index f8c7d4d97..53018d23c 100644 --- a/src/core/session-row-enrichment.ts +++ b/src/core/session-row-enrichment.ts @@ -1,54 +1,11 @@ // src/core/session-row-enrichment.ts // -// Presentation enrichment for GET /api/sessions rows: bot avatar (from the -// daemon's bots-info.json, mtime-cached) + git repo name/branch (resolved -// from workingDir, TTL-cached). Stamped in the dashboard handler so every -// consumer (board, Desktop sidebar) shares one additive row shape; rows from -// older daemons simply never carry the new fields. +// Git presentation metadata for dashboard session rows. Resolution happens in +// the central dashboard read-model (not in an HTTP request handler), so REST +// snapshots and SSE updates share one row shape. import { execFile } from 'node:child_process'; -import { readFileSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; - -// ── Bot avatar map (bots-info.json, mtime-cached) ───────────────────────── - -type BotsInfoEntry = { - larkAppId?: string; - botAvatarUrl?: string | null; -}; - -let avatarCache: { path: string; mtimeMs: number; map: Map } | null = null; - -/** larkAppId → bot avatar URL from bots-info.json; empty map when unreadable. */ -export function getBotAvatarByAppId(dataDir: string): Map { - const path = join(dataDir, 'bots-info.json'); - let mtimeMs = -1; - try { - mtimeMs = statSync(path).mtimeMs; - } catch { - // File missing — cache the empty map keyed on mtime -1. - } - if (avatarCache && avatarCache.path === path && avatarCache.mtimeMs === mtimeMs) { - return avatarCache.map; - } - const map = new Map(); - if (mtimeMs >= 0) { - try { - const entries = JSON.parse(readFileSync(path, 'utf8')) as BotsInfoEntry[]; - if (Array.isArray(entries)) { - for (const e of entries) { - const appId = typeof e?.larkAppId === 'string' ? e.larkAppId : ''; - const url = typeof e?.botAvatarUrl === 'string' ? e.botAvatarUrl.trim() : ''; - if (appId && url) map.set(appId, url); - } - } - } catch { - /* unreadable → empty map */ - } - } - avatarCache = { path, mtimeMs, map }; - return map; -} +import { basename } from 'node:path'; // ── Git repo info (per-cwd TTL cache) ───────────────────────────────────── @@ -59,6 +16,11 @@ export type GitRepoInfo = { branch: string | null; }; +export interface GitRepoResolveOptions { + /** Bypass a possibly stale positive cache at a known turn boundary. */ + force?: boolean; +} + const GIT_INFO_OK_TTL_MS = 60_000; const GIT_INFO_MISS_TTL_MS = 300_000; const GIT_TIMEOUT_MS = 1_500; @@ -66,9 +28,14 @@ const GIT_TIMEOUT_MS = 1_500; * fork-bomb the host). */ const GIT_MAX_CONCURRENT_PROBES = 8; -const gitInfoCache = new Map(); +const gitInfoCache = new Map(); /** Dedup so a poll burst spawns at most one git probe per cwd. */ const gitInfoInflight = new Map>(); +let gitProbeSequence = 0; let gitProbesRunning = 0; const gitProbeQueue: Array<() => void> = []; @@ -109,16 +76,24 @@ async function probeGitRepoInfo(cwd: string): Promise { } /** Resolve repoName/branch for a session cwd; null when not a git repo. Never throws. */ -export async function getGitRepoInfo(cwd: string): Promise { +export async function getGitRepoInfo( + cwd: string, + options: GitRepoResolveOptions = {}, +): Promise { const dir = cwd.trim(); if (!dir) return null; const now = Date.now(); const hit = gitInfoCache.get(dir); - if (hit && now - hit.at < (hit.info ? GIT_INFO_OK_TTL_MS : GIT_INFO_MISS_TTL_MS)) { + if ( + !options.force + && hit + && now - hit.at < (hit.info ? GIT_INFO_OK_TTL_MS : GIT_INFO_MISS_TTL_MS) + ) { return hit.info; } const inflight = gitInfoInflight.get(dir); - if (inflight) return inflight; + if (!options.force && inflight) return inflight; + const sequence = ++gitProbeSequence; const p = (async (): Promise => { try { return await withGitProbeSlot(() => probeGitRepoInfo(dir)); @@ -129,57 +104,19 @@ export async function getGitRepoInfo(cwd: string): Promise { gitInfoInflight.set(dir, p); try { const info = await p; - gitInfoCache.set(dir, { at: Date.now(), info }); + const current = gitInfoCache.get(dir); + // A force refresh started later must remain authoritative even if an older + // probe happens to finish after it. + if (!current || sequence >= current.sequence) { + gitInfoCache.set(dir, { at: Date.now(), info, sequence }); + } return info; } finally { - gitInfoInflight.delete(dir); + if (gitInfoInflight.get(dir) === p) gitInfoInflight.delete(dir); } } -// ── Row stamping ────────────────────────────────────────────────────────── - -type RowWithEnrichmentHints = { - larkAppId?: string; - workingDir?: string; -}; - -/** Test hook: clear both caches. */ +/** Test hook: clear the resolver cache. */ export function clearSessionRowEnrichmentCaches(): void { - avatarCache = null; gitInfoCache.clear(); } - -/** - * Stamp botAvatarUrl + repoName + gitBranch onto session rows. Best-effort - * and additive: avatars come from bots-info.json (sync, cheap); git info is - * probed per workingDir with a TTL cache. A total time budget keeps a wedged - * filesystem from stalling /api/sessions — late probes still land in the - * cache and show up on the next poll. - */ -export async function enrichSessionRowsForPresentation( - rows: readonly T[], - dataDir: string, -): Promise> { - const avatars = getBotAvatarByAppId(dataDir); - const withAvatar = rows.map((row) => { - const botAvatarUrl = row.larkAppId ? avatars.get(row.larkAppId) : undefined; - return botAvatarUrl ? { ...row, botAvatarUrl } : row; - }); - const withGit = withAvatar.map(async (row) => { - if (!row.workingDir) return row; - const git = await getGitRepoInfo(row.workingDir); - if (!git) return row; - return { - ...row, - ...(git.repoName ? { repoName: git.repoName } : {}), - ...(git.branch ? { gitBranch: git.branch } : {}), - }; - }); - const GIT_BUDGET_MS = 2_500; - return Promise.race([ - Promise.all(withGit), - new Promise((resolve) => - setTimeout(() => resolve(withAvatar), GIT_BUDGET_MS), - ), - ]); -} diff --git a/src/daemon.ts b/src/daemon.ts index 6fb0ae2c0..8c3df8666 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -266,8 +266,6 @@ import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, registerAsk as registerAskBroker, - listPendingAsks, - submitAskFromDesktop, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js'; @@ -4159,49 +4157,6 @@ ipcRoute('POST', '/api/asks', async (req, res) => { return jsonRes(res, 200, result); }); -// Desktop / dashboard: list pending ask-hooks for operator UI (not agent-facing). -ipcRoute('GET', '/api/asks/pending', (req, res) => { - if (!isTrustedHostIpcRequest(req)) { - return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); - } - const asks = listPendingAsks().map((a) => ({ - askId: a.askId, - sessionId: a.sessionId, - larkAppId: a.larkAppId, - chatId: a.chatId, - rootMessageId: a.rootMessageId, - questions: a.questions, - deadlineAt: a.deadlineAt, - createdAt: a.createdAt, - })); - return jsonRes(res, 200, { asks }); -}); - -// Desktop answers an ask (bypasses Feishu canTalk; trusted host only). -ipcRoute('POST', '/api/asks/answer', async (req, res) => { - if (!isTrustedHostIpcRequest(req)) { - return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' }); - } - let body: { askId?: string; selections?: string[][]; by?: string }; - try { - body = await readJsonBody(req); - } catch { - return jsonRes(res, 400, { ok: false, error: 'bad_json' }); - } - if (!body.askId || !Array.isArray(body.selections)) { - return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' }); - } - const outcome = submitAskFromDesktop({ - askId: body.askId, - selections: body.selections, - by: typeof body.by === 'string' ? body.by : 'desktop', - }); - if (outcome !== 'accepted') { - return jsonRes(res, 409, { ok: false, error: outcome }); - } - return jsonRes(res, 200, { ok: true, outcome }); -}); - // ─── attention IPC route (internal: set needs-you state) ───────────────────── // // NOT an agent-facing command. `botmux send --attention` posts the message diff --git a/src/dashboard.ts b/src/dashboard.ts index ef20218af..e56babe3e 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -19,6 +19,11 @@ import { } from './dashboard/auth.js'; import { DaemonRegistry } from './dashboard/registry.js'; import { Aggregator, subscribeDaemon } from './dashboard/aggregator.js'; +import { createSessionPresentationCoordinator } from './dashboard/session-presentation.js'; +import { + parseDashboardAskAnswerRequest, + proxyDashboardAskAnswer, +} from './dashboard/desktop-asks.js'; import { pickCreatorForGroup } from './dashboard/operator-selector.js'; import { buildTeamGroupCreatePayload, planGroupCreator } from './dashboard/team-group.js'; import { jsonRes } from './dashboard/http.js'; @@ -32,7 +37,12 @@ import { } from './workflows/v3/daemon-ipc-auth.js'; import { handleDashboardTriggerApi } from './dashboard/trigger-api.js'; import { handleConnectorApi } from './dashboard/connector-api.js'; -import { redactGroupsForPublic, redactSchedulesForPublic } from './dashboard/public-redact.js'; +import { + redactGroupsForPublic, + redactSchedulesForPublic, + redactSessionEventForPublic, + redactSessionsForPublic, +} from './dashboard/public-redact.js'; import { handleWebhookRoute } from './dashboard/webhook-routes.js'; import { handleFederationApi } from './dashboard/federation-api.js'; import { handleFederationSpokeApi, syncAllMemberships, autoBindOwnerIfUnambiguous, type TeamSessionRowLike } from './dashboard/federation-spoke-api.js'; @@ -54,7 +64,7 @@ import { hostLocalTimeZone, scheduleTimeZone } from './utils/timezone.js'; import { buildDashboardUrls, type DashboardUrls } from './core/dashboard-url.js'; import { resolveBotmuxDataDir } from './core/data-dir.js'; import { dashboardSecretPath } from './core/dashboard-secret.js'; -import { enrichSessionRowsForPresentation } from './core/session-row-enrichment.js'; +import { getGitRepoInfo } from './core/session-row-enrichment.js'; import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js'; import { isLocalDevInstall, botmuxVersion, botmuxVersionAt, botmuxCliEntry, botmuxInstallRoot } from './utils/install-info.js'; import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js'; @@ -148,7 +158,10 @@ import { startPlatformTunnelClient, type PlatformBotInfo, type PlatformTeamSyncM import { applyPlatformTeamSync, getPlatformTeamSyncRev, listPlatformTeams } from './services/platform-team-store.js'; import { getBotUnionId } from './services/bot-union-ids-store.js'; import { cleanupIdleSessions, parseIdleCleanupHours } from './dashboard/session-cleanup.js'; -import { handleDesktopCompat } from './dashboard/compat.js'; +import { + compatMachineIdForAuthenticatedRequest, + handleDesktopCompat, +} from './dashboard/compat.js'; import { isDashboardChunkJsPath, missingDashboardChunkModule } from './dashboard/stale-chunk-module.js'; import { aggregateRoleBatch, parseRoleBatchTargets } from './dashboard/roles-batch.js'; import { automateOpenPlatformSetup, vcListenerEventGateError } from './setup/open-platform-automation.js'; @@ -272,6 +285,12 @@ function verifyDashboardBinding(port: number): Promise { mkdirSync(REGISTRY_DIR, { recursive: true }); const registry = new DaemonRegistry(REGISTRY_DIR); const aggregator = new Aggregator(); +const sessionPresentation = createSessionPresentationCoordinator(aggregator, getGitRepoInfo); + +// Keep Git-derived fields in the central read-model so REST snapshots and SSE +// share one row shape. Idle/limited turn boundaries force a low-frequency +// branch refresh after the CLI has had a chance to change repositories. +aggregator.on(sessionPresentation.onEvent); /** * Resolve which daemon owns a schedule row. For rows with an explicit @@ -1046,7 +1065,11 @@ async function attachDaemon(d: import('./dashboard/registry.js').DaemonInfo): Pr ]); const s = await sRes.json() as { sessions: any[] }; const sch = await schRes.json() as { schedules: any[] }; - aggregator.hydrateSessions(d.larkAppId, s.sessions ?? []); + const rows = (s.sessions ?? []).map((row) => ( + d.botAvatarUrl ? { ...row, botAvatarUrl: d.botAvatarUrl } : row + )); + aggregator.hydrateSessions(d.larkAppId, rows); + for (const row of rows) sessionPresentation.schedule(d.larkAppId, row); aggregator.hydrateSchedules(sch.schedules ?? []); } catch (e: any) { logger.warn(`[dashboard] hydrate ${d.larkAppId}: ${e.message ?? e}`); @@ -1067,14 +1090,24 @@ async function attachDaemon(d: import('./dashboard/registry.js').DaemonInfo): Pr } function syncSubscriptions(): void { - const online = new Set(registry.list().map(d => d.larkAppId)); + const daemons = registry.list(); + const online = new Set(daemons.map(d => d.larkAppId)); // Attach (hydrate + subscribe) any newly-online daemon. Fire-and-forget // because the registry callback is sync and the attach is per-daemon // independent. - for (const d of registry.list()) { + for (const d of daemons) { if (!subs.has(d.larkAppId)) { void attachDaemon(d); } + if (d.botAvatarUrl) { + for (const row of aggregator.getSessions()) { + if (row.larkAppId !== d.larkAppId || row.botAvatarUrl === d.botAvatarUrl) continue; + aggregator.applyEvent(d.larkAppId, { + type: 'session.update', + body: { sessionId: row.sessionId, patch: { botAvatarUrl: d.botAvatarUrl } }, + }); + } + } } // Close subscriptions for daemons that went offline. Cache entries are // intentionally retained — the user may still want to see the last-known @@ -2153,7 +2186,17 @@ const server = createServer(async (req, res) => { // Desktop shell compatibility probe (read-only, no token required). Keep it // outside the browser auth gate so packaged desktop apps can decide whether // this runtime speaks their dashboard protocol before loading the SPA. - if (handleDesktopCompat(req, res, url)) { + if (req.method === 'GET' && url.pathname === '/__desktop/compat') { + const presentedToken = authedToken(req, url); + const boundMachineId = activeToken && presentedToken === activeToken + ? readPlatformBinding()?.machineId + : null; + const compatMachineId = compatMachineIdForAuthenticatedRequest( + presentedToken, + activeToken, + boundMachineId, + ); + handleDesktopCompat(req, res, url, { machineId: compatMachineId ?? undefined }); return; } @@ -2400,10 +2443,9 @@ const server = createServer(async (req, res) => { ? { ...s, botName: n } : s; }); - // Presentation enrichment (bot avatar + git repo/branch) — additive, - // cached, best-effort; Desktop sidebar renders these, board ignores them. - const enriched = await enrichSessionRowsForPresentation(sessions, config.session.dataDir); - return jsonRes(res, 200, { sessions: enriched }); + return jsonRes(res, 200, { + sessions: authed ? sessions : redactSessionsForPublic(sessions), + }); } // Desktop / operator UI: aggregate pending ask-hooks across daemons. @@ -2412,7 +2454,9 @@ const server = createServer(async (req, res) => { const asks: unknown[] = []; await Promise.all(daemons.map(async (d) => { try { - const upstream = await fetchDaemonIpc(d.ipcPort, '/api/asks/pending'); + const upstream = await fetchDaemonIpc(d.ipcPort, '/api/asks/pending', { + signal: AbortSignal.timeout(2_000), + }); if (!upstream.ok) return; const body = await upstream.json() as { asks?: unknown[] }; for (const a of body.asks ?? []) { @@ -2430,47 +2474,20 @@ const server = createServer(async (req, res) => { } if (req.method === 'POST' && url.pathname === '/api/asks/answer') { - let body: { askId?: string; selections?: string[][]; by?: string; larkAppId?: string }; + let body: unknown; try { - body = await readJsonBody(req) as typeof body; + body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } - if (!body.askId || !Array.isArray(body.selections)) { - return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' }); - } - // Prefer explicit bot; else try each daemon until one accepts. - const candidates = body.larkAppId - ? registry.list().filter((d) => d.larkAppId === body.larkAppId) - : registry.list(); - for (const d of candidates) { - try { - const upstream = await proxyToDaemon(d.larkAppId, '/api/asks/answer', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - askId: body.askId, - selections: body.selections, - by: body.by ?? 'desktop', - }), - }); - const text = await upstream.text(); - if (upstream.ok) { - res.writeHead(upstream.status, { 'content-type': 'application/json' }); - res.end(text); - return; - } - // 409 stale on wrong daemon — try next - if (upstream.status !== 409) { - res.writeHead(upstream.status, { 'content-type': 'application/json' }); - res.end(text); - return; - } - } catch { - /* try next */ - } + const parsed = parseDashboardAskAnswerRequest(body); + if (!parsed.ok) { + return jsonRes(res, 400, { ok: false, error: parsed.error }); } - return jsonRes(res, 409, { ok: false, error: 'stale' }); + const upstream = await proxyDashboardAskAnswer(parsed.value, proxyToDaemon); + res.writeHead(upstream.status, { 'content-type': upstream.contentType }); + res.end(upstream.body); + return; } if (req.method === 'POST' && url.pathname === '/api/sessions/cleanup-idle') { let body: { olderThanHours?: unknown; sessionIds?: unknown }; @@ -4502,7 +4519,9 @@ const server = createServer(async (req, res) => { // full task object — strip the prompt AND workingDir for anonymous SSE // listeners, or the REST-side scrub would be trivially bypassed by // `/events`. - let body = ev.body; + let body = authed + ? ev.body + : redactSessionEventForPublic(ev.type, ev.body) as typeof ev.body; if (!authed && (ev.type === 'schedule.created' || ev.type === 'schedule.updated')) { const b = body as { schedule?: Record; patch?: Record; id?: string }; body = { diff --git a/src/dashboard/aggregator.ts b/src/dashboard/aggregator.ts index f45009b97..c65ad8c92 100644 --- a/src/dashboard/aggregator.ts +++ b/src/dashboard/aggregator.ts @@ -4,6 +4,19 @@ import type { DashboardEvent } from '../core/dashboard-events.js'; type Row = { sessionId: string; larkAppId: string; [k: string]: unknown }; type Sched = { id: string; [k: string]: unknown }; +const SESSION_PRESENTATION_FIELDS = ['botAvatarUrl', 'repoName', 'gitBranch'] as const; + +function mergeSpawnedRow(current: Row | undefined, incoming: Row, larkAppId: string): Row { + const next: Row = { ...incoming, larkAppId }; + if (current && current.workingDir === next.workingDir) { + for (const field of SESSION_PRESENTATION_FIELDS) { + if (next[field] === undefined && current[field] !== undefined) { + next[field] = current[field]; + } + } + } + return next; +} /** * Aggregates session and schedule state across all online daemons. @@ -17,15 +30,29 @@ export class Aggregator { private listeners = new Set<(e: DashboardEvent & { larkAppId: string }) => void>(); applyEvent(larkAppId: string, ev: DashboardEvent): void { + let emitted = ev; switch (ev.type) { case 'session.spawned': { const r = ev.body.session as Row; - this.sessions.set(r.sessionId, { ...r, larkAppId }); + const next = mergeSpawnedRow(this.sessions.get(r.sessionId), r, larkAppId); + this.sessions.set(r.sessionId, next); + emitted = { ...ev, body: { session: next } }; break; } case 'session.update': { const cur = this.sessions.get(ev.body.sessionId); - if (cur) this.sessions.set(ev.body.sessionId, { ...cur, ...ev.body.patch }); + if (cur) { + const patch = { ...ev.body.patch }; + if ( + Object.prototype.hasOwnProperty.call(patch, 'workingDir') + && patch.workingDir !== cur.workingDir + ) { + patch.repoName = null; + patch.gitBranch = null; + } + this.sessions.set(ev.body.sessionId, { ...cur, ...patch }); + emitted = { ...ev, body: { ...ev.body, patch } }; + } break; } case 'session.exited': { @@ -47,19 +74,22 @@ export class Aggregator { // schedule.fired and heartbeat are pass-through (no cache mutation) } for (const fn of this.listeners) { - try { fn({ ...ev, larkAppId } as any); } catch { /* swallow */ } + try { fn({ ...emitted, larkAppId } as any); } catch { /* swallow */ } } } /** Bulk-load on dashboard start before SSE catches up. Idempotent. */ hydrateSessions(larkAppId: string, rows: Row[]): void { - for (const r of rows) this.sessions.set(r.sessionId, { ...r, larkAppId }); + for (const r of rows) { + this.sessions.set(r.sessionId, mergeSpawnedRow(this.sessions.get(r.sessionId), r, larkAppId)); + } } hydrateSchedules(rows: Sched[]): void { for (const r of rows) this.schedules.set(r.id, r); } getSessions(): Row[] { return [...this.sessions.values()]; } + getSession(sessionId: string): Row | undefined { return this.sessions.get(sessionId); } getSchedules(): Sched[] { return [...this.schedules.values()]; } /** sessionId → owning daemon's larkAppId (used for write routing). */ @@ -137,6 +167,9 @@ export function subscribeDaemon( const data = line.slice(5).trim(); try { const body = JSON.parse(data); + if (evt === 'session.spawned' && d.botAvatarUrl && body?.session) { + body.session = { ...body.session, botAvatarUrl: d.botAvatarUrl }; + } agg.applyEvent(d.larkAppId, { type: evt, body } as any); } catch { // Skip malformed frame diff --git a/src/dashboard/compat.ts b/src/dashboard/compat.ts index fcd922bed..74b4a6165 100644 --- a/src/dashboard/compat.ts +++ b/src/dashboard/compat.ts @@ -1,6 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { readPlatformBinding } from '../platform/binding.js'; import { botmuxInstallRoot, botmuxVersion } from '../utils/install-info.js'; import { resolveEffectiveBotmuxVersion } from '../utils/version-info.js'; import { jsonRes } from './http.js'; @@ -10,39 +9,46 @@ export interface DashboardCompatModule { route?: string; } -export type DashboardCompatCapability = - | 'asks.answer' - | 'asks.read' - | 'bots.configure' - | 'bots.read' - | 'connectors.manage' - | 'connectors.read' - | 'groups.manage' - | 'groups.read' - | 'insights.read' - | 'monitoring.read' - | 'office.read' - | 'plugins.manage' - | 'plugins.read' - | 'roles.manage' - | 'roles.read' - | 'schedules.manage' - | 'schedules.read' - | 'sessions.manage' - | 'sessions.read' - | 'sessions.terminal' - | 'settings.manage' - | 'settings.read' - | 'skills.manage' - | 'skills.read' - | 'team.manage' - | 'team.read' - | 'updates.manage' - | 'updates.read' - | 'whiteboards.manage' - | 'whiteboards.read' - | 'workflow.manage' - | 'workflow.read'; +const DASHBOARD_MODULE_SPECS = { + overview: { + supported: true, + route: '#/', + capabilities: ['overview.read'], + }, + sessions: { + supported: true, + route: '#/sessions', + capabilities: ['sessions.read', 'sessions.manage', 'sessions.terminal', 'asks.read', 'asks.answer'], + }, + groups: { supported: true, route: '#/groups', capabilities: ['groups.read', 'groups.manage'] }, + roles: { supported: true, route: '#/roles', capabilities: ['roles.read', 'roles.manage'] }, + monitoring: { supported: true, route: '#/monitoring', capabilities: ['monitoring.read'] }, + insights: { supported: true, route: '#/insights', capabilities: ['insights.read'] }, + schedules: { supported: true, route: '#/schedules', capabilities: ['schedules.read', 'schedules.manage'] }, + whiteboards: { supported: true, route: '#/whiteboards', capabilities: ['whiteboards.read', 'whiteboards.manage'] }, + office: { supported: true, route: '#/office', capabilities: ['office.read'] }, + bots: { supported: true, route: '#/bot-defaults', capabilities: ['bots.read', 'bots.configure'] }, + skills: { supported: true, route: '#/skills', capabilities: ['skills.read', 'skills.manage'] }, + plugins: { supported: true, route: '#/plugins', capabilities: ['plugins.read', 'plugins.manage'] }, + team: { supported: true, route: '#/team', capabilities: ['team.read', 'team.manage'] }, + connectors: { supported: true, route: '#/connectors', capabilities: ['connectors.read', 'connectors.manage'] }, + settings: { + supported: true, + route: '#/settings', + capabilities: ['settings.read', 'settings.manage', 'updates.read', 'updates.manage'], + }, + workflow: { + supported: false, + capabilities: ['workflow.read', 'workflow.manage'], + }, +} as const satisfies Record; + +type DashboardModuleSpec = typeof DASHBOARD_MODULE_SPECS[keyof typeof DASHBOARD_MODULE_SPECS]; +export type DashboardCompatCapability = DashboardModuleSpec['capabilities'][number]; export interface DesktopCompatManifest { schemaVersion: 1; @@ -72,24 +78,6 @@ export interface BuildCompatManifestOptions { machineId?: string | null; } -const DASHBOARD_CORE_ROUTES = [ - '#/', - '#/sessions', - '#/groups', - '#/roles', - '#/monitoring', - '#/insights', - '#/schedules', - '#/whiteboards', - '#/office', - '#/bot-defaults', - '#/skills', - '#/plugins', - '#/team', - '#/connectors', - '#/settings', -] as const; - const DASHBOARD_COMPAT_FEATURES = [ 'desktop-shell', 'dashboard-protocol-v1', @@ -98,66 +86,37 @@ const DASHBOARD_COMPAT_FEATURES = [ 'dashboard-capabilities', ] as const; -const DASHBOARD_MODULES = { - overview: { supported: true, route: '#/' }, - sessions: { supported: true, route: '#/sessions' }, - groups: { supported: true, route: '#/groups' }, - roles: { supported: true, route: '#/roles' }, - monitoring: { supported: true, route: '#/monitoring' }, - insights: { supported: true, route: '#/insights' }, - schedules: { supported: true, route: '#/schedules' }, - whiteboards: { supported: true, route: '#/whiteboards' }, - office: { supported: true, route: '#/office' }, - bots: { supported: true, route: '#/bot-defaults' }, - skills: { supported: true, route: '#/skills' }, - plugins: { supported: true, route: '#/plugins' }, - team: { supported: true, route: '#/team' }, - connectors: { supported: true, route: '#/connectors' }, - settings: { supported: true, route: '#/settings' }, - workflow: { supported: false }, -} satisfies Record; +function buildDashboardModules(): Record { + return Object.fromEntries( + Object.entries(DASHBOARD_MODULE_SPECS).map(([id, spec]) => [ + id, + { + supported: spec.supported, + ...('route' in spec ? { route: spec.route } : {}), + }, + ]), + ); +} -const DASHBOARD_CAPABILITIES: Record = { - 'asks.answer': true, - 'asks.read': true, - 'bots.configure': true, - 'bots.read': true, - 'connectors.manage': true, - 'connectors.read': true, - 'groups.manage': true, - 'groups.read': true, - 'insights.read': true, - 'monitoring.read': true, - 'office.read': true, - 'plugins.manage': true, - 'plugins.read': true, - 'roles.manage': true, - 'roles.read': true, - 'schedules.manage': true, - 'schedules.read': true, - 'sessions.manage': true, - 'sessions.read': true, - 'sessions.terminal': true, - 'settings.manage': true, - 'settings.read': true, - 'skills.manage': true, - 'skills.read': true, - 'team.manage': true, - 'team.read': true, - 'updates.manage': true, - 'updates.read': true, - 'whiteboards.manage': true, - 'whiteboards.read': true, - 'workflow.manage': false, - 'workflow.read': false, -}; +function buildDashboardCapabilities(): Record { + const capabilities: Partial> = {}; + for (const spec of Object.values(DASHBOARD_MODULE_SPECS)) { + for (const capability of spec.capabilities) { + capabilities[capability] = spec.supported; + } + } + return capabilities as Record; +} + +function buildDashboardRoutes(): string[] { + return Object.values(DASHBOARD_MODULE_SPECS) + .filter((spec): spec is DashboardModuleSpec & { route: string } => + spec.supported && 'route' in spec) + .map(spec => spec.route); +} export function buildCompatManifest(options: BuildCompatManifestOptions = {}): DesktopCompatManifest { - const machineId = normalizeMachineId( - options.machineId === undefined - ? readPlatformBinding()?.machineId - : options.machineId, - ); + const machineId = normalizeMachineId(options.machineId); return { schemaVersion: 1, @@ -172,9 +131,9 @@ export function buildCompatManifest(options: BuildCompatManifestOptions = {}): D ? { runtimeIdentity: { source: 'platform-binding' as const, machineId } } : {}), features: [...DASHBOARD_COMPAT_FEATURES], - routes: [...DASHBOARD_CORE_ROUTES], - modules: structuredClone(DASHBOARD_MODULES), - capabilities: { ...DASHBOARD_CAPABILITIES }, + routes: buildDashboardRoutes(), + modules: buildDashboardModules(), + capabilities: buildDashboardCapabilities(), }; } @@ -183,8 +142,22 @@ function normalizeMachineId(value: string | null | undefined): string | undefine return machineId ? machineId : undefined; } -export function handleDesktopCompat(req: IncomingMessage, res: ServerResponse, url: URL): boolean { +export function compatMachineIdForAuthenticatedRequest( + presentedToken: string | undefined, + activeToken: string | null | undefined, + boundMachineId: string | null | undefined, +): string | null { + if (!activeToken || presentedToken !== activeToken) return null; + return normalizeMachineId(boundMachineId) ?? null; +} + +export function handleDesktopCompat( + req: IncomingMessage, + res: ServerResponse, + url: URL, + options: BuildCompatManifestOptions = {}, +): boolean { if (req.method !== 'GET' || url.pathname !== '/__desktop/compat') return false; - jsonRes(res, 200, buildCompatManifest()); + jsonRes(res, 200, buildCompatManifest(options)); return true; } diff --git a/src/dashboard/desktop-asks.ts b/src/dashboard/desktop-asks.ts new file mode 100644 index 000000000..68c66f54c --- /dev/null +++ b/src/dashboard/desktop-asks.ts @@ -0,0 +1,74 @@ +export interface DashboardAskAnswerRequest { + askId: string; + larkAppId: string; + selections: string[][]; + by?: string; +} + +export type DashboardAskAnswerParseResult = + | { ok: true; value: DashboardAskAnswerRequest } + | { ok: false; error: 'askId_larkAppId_and_selections_required' | 'invalid_selections' }; + +export interface DashboardAskAnswerProxyResult { + status: number; + contentType: string; + body: string; +} + +export function parseDashboardAskAnswerRequest(raw: unknown): DashboardAskAnswerParseResult { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, error: 'askId_larkAppId_and_selections_required' }; + } + const body = raw as Record; + const askId = typeof body.askId === 'string' ? body.askId.trim() : ''; + const larkAppId = typeof body.larkAppId === 'string' ? body.larkAppId.trim() : ''; + if (!askId || !larkAppId || !Array.isArray(body.selections)) { + return { ok: false, error: 'askId_larkAppId_and_selections_required' }; + } + if (!body.selections.every( + selection => Array.isArray(selection) && selection.every(key => typeof key === 'string'), + )) { + return { ok: false, error: 'invalid_selections' }; + } + return { + ok: true, + value: { + askId, + larkAppId, + selections: body.selections as string[][], + ...(typeof body.by === 'string' ? { by: body.by } : {}), + }, + }; +} + +export async function proxyDashboardAskAnswer( + request: DashboardAskAnswerRequest, + proxyToDaemon: ( + larkAppId: string, + path: string, + init: RequestInit, + ) => Promise, +): Promise { + try { + const upstream = await proxyToDaemon(request.larkAppId, '/api/asks/answer', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + askId: request.askId, + selections: request.selections, + by: request.by ?? 'desktop', + }), + }); + return { + status: upstream.status, + contentType: upstream.headers.get('content-type') ?? 'application/json', + body: await upstream.text(), + }; + } catch { + return { + status: 503, + contentType: 'application/json', + body: JSON.stringify({ ok: false, error: 'daemon_unavailable' }), + }; + } +} diff --git a/src/dashboard/public-redact.ts b/src/dashboard/public-redact.ts index 55dcacb04..91959b114 100644 --- a/src/dashboard/public-redact.ts +++ b/src/dashboard/public-redact.ts @@ -58,3 +58,36 @@ export function redactSchedulesForPublic(schedules: unknown[]): unknown[] { return rest; }); } + +/** Branch names often carry issue/customer identifiers. `/api/sessions` and + * `/events` are both public-read surfaces, so keep one non-mutating projection + * for their shared session row shape. */ +export function redactSessionForPublic(session: unknown): unknown { + if (!session || typeof session !== 'object' || Array.isArray(session)) return session; + const { gitBranch: _gitBranch, ...rest } = session as Record; + return rest; +} + +export function redactSessionsForPublic(sessions: unknown[]): unknown[] { + if (!Array.isArray(sessions)) return sessions; + return sessions.map(redactSessionForPublic); +} + +/** Apply the same branch-name policy to session rows delivered over SSE. */ +export function redactSessionEventForPublic(type: string, body: unknown): unknown { + if (!body || typeof body !== 'object' || Array.isArray(body)) return body; + const eventBody = body as Record; + if (type === 'session.spawned') { + return { ...eventBody, session: redactSessionForPublic(eventBody.session) }; + } + if ( + type === 'session.update' + && eventBody.patch + && typeof eventBody.patch === 'object' + && !Array.isArray(eventBody.patch) + ) { + const { gitBranch: _gitBranch, ...patch } = eventBody.patch as Record; + return { ...eventBody, patch }; + } + return body; +} diff --git a/src/dashboard/session-presentation.ts b/src/dashboard/session-presentation.ts new file mode 100644 index 000000000..66228da39 --- /dev/null +++ b/src/dashboard/session-presentation.ts @@ -0,0 +1,87 @@ +import type { DashboardEvent } from '../core/dashboard-events.js'; +import type { + GitRepoInfo, + GitRepoResolveOptions, +} from '../core/session-row-enrichment.js'; +import type { Aggregator } from './aggregator.js'; + +type PresentationRow = Record; +type AggregatedEvent = DashboardEvent & { larkAppId: string }; +type ScheduleOptions = { force?: boolean }; + +function presentationString(value: unknown): string | null { + return typeof value === 'string' && value ? value : null; +} + +export function createSessionPresentationCoordinator( + aggregator: Aggregator, + resolveGit: ( + workingDir: string, + options?: GitRepoResolveOptions, + ) => Promise, +): { + schedule: (larkAppId: string, row: PresentationRow, options?: ScheduleOptions) => void; + onEvent: (event: AggregatedEvent) => void; +} { + const pending = new Map(); + const schedule = ( + larkAppId: string, + row: PresentationRow, + options: ScheduleOptions = {}, + ): void => { + const sessionId = typeof row.sessionId === 'string' ? row.sessionId : ''; + const workingDir = typeof row.workingDir === 'string' ? row.workingDir.trim() : ''; + if (!sessionId || !workingDir) return; + const token = Symbol(sessionId); + pending.set(sessionId, token); + + const lookup = options.force + ? resolveGit(workingDir, { force: true }) + : resolveGit(workingDir); + void lookup.then((info) => { + if (pending.get(sessionId) !== token) return; + const current = aggregator.getSession(sessionId); + if (!current || current.larkAppId !== larkAppId || current.workingDir !== workingDir) return; + const repoName = info?.repoName ?? null; + const gitBranch = info?.branch ?? null; + if ( + presentationString(current.repoName) === repoName + && presentationString(current.gitBranch) === gitBranch + ) { + return; + } + aggregator.applyEvent(larkAppId, { + type: 'session.update', + body: { sessionId, patch: { repoName, gitBranch } }, + }); + }).catch(() => { + // Presentation enrichment is best-effort; the canonical row remains valid. + }).finally(() => { + if (pending.get(sessionId) === token) pending.delete(sessionId); + }); + }; + + return { + schedule, + onEvent(event) { + if (event.type === 'session.spawned') { + const row = event.body.session as PresentationRow; + schedule(event.larkAppId, row, { + force: row.status === 'idle' || row.status === 'limited', + }); + return; + } + if (event.type === 'session.update') { + const workingDirChanged = Object.prototype.hasOwnProperty.call( + event.body.patch, + 'workingDir', + ); + const atTurnBoundary = event.body.patch.status === 'idle' + || event.body.patch.status === 'limited'; + if (!workingDirChanged && !atTurnBoundary) return; + const current = aggregator.getSession(event.body.sessionId); + if (current) schedule(event.larkAppId, current, { force: atTurnBoundary }); + } + }, + }; +} diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx index 77b6871f4..51abba40d 100644 --- a/src/dashboard/web/app.tsx +++ b/src/dashboard/web/app.tsx @@ -30,6 +30,7 @@ import { initFloatingScrollbars } from './floating-scrollbars.js'; import { PLUGIN_PINS_CHANGED_EVENT } from './plugin-events.js'; import { updateAndRestartBotmux, type BotmuxUpdatePhase } from './update-action.js'; import { + canonicalDashboardClientShellUrl, dashboardClientShellRedirect, readDashboardClientShell, } from './client-shell.js'; @@ -1354,6 +1355,10 @@ function initOwnerAvatar(): void { } void (async () => { + const canonicalClientShellUrl = canonicalDashboardClientShellUrl(window.location.href); + if (canonicalClientShellUrl) { + window.history.replaceState(window.history.state, '', canonicalClientShellUrl); + } ui.init(); applyShellLocaleFromHash(); const host = document.getElementById('app-root'); diff --git a/src/dashboard/web/client-shell.ts b/src/dashboard/web/client-shell.ts index 318ed04ad..638a36970 100644 --- a/src/dashboard/web/client-shell.ts +++ b/src/dashboard/web/client-shell.ts @@ -9,6 +9,32 @@ function normalizeClientShell(value: string | null): DashboardClientShell | null : null; } +/** + * Upgrade a legacy hash-scoped shell marker into the durable URL query. + * Returns the replacement URL, or null when no rewrite is needed/possible. + */ +export function canonicalDashboardClientShellUrl(href: string): string | null { + try { + const url = new URL(href); + if (normalizeClientShell(url.searchParams.get(CLIENT_SHELL_PARAM))) return null; + + const queryIndex = url.hash.indexOf('?'); + if (queryIndex < 0) return null; + const hashParams = new URLSearchParams(url.hash.slice(queryIndex + 1)); + const shell = normalizeClientShell(hashParams.get(CLIENT_SHELL_PARAM)); + if (!shell) return null; + + const hashPath = url.hash.slice(0, queryIndex) || '#/'; + hashParams.delete(CLIENT_SHELL_PARAM); + const remainingHashQuery = hashParams.toString(); + url.searchParams.set(CLIENT_SHELL_PARAM, shell); + url.hash = remainingHashQuery ? `${hashPath}?${remainingHashQuery}` : hashPath; + return url.toString(); + } catch { + return null; + } +} + /** * Detect the restricted Desktop/Mobile dashboard shell. * diff --git a/src/dashboard/web/store.ts b/src/dashboard/web/store.ts index 085bc3778..56e588aee 100644 --- a/src/dashboard/web/store.ts +++ b/src/dashboard/web/store.ts @@ -35,12 +35,12 @@ class Store { } } - upsertSessions(rows: Session[]) { - for (const r of rows) this.sessions.set(r.sessionId, r); - this.emit(); - } - upsertSchedules(rows: Schedule[]) { - for (const r of rows) this.schedules.set(r.id, r); + replaceSnapshot(rows: Session[], schedules: Schedule[], scheduleTimeZone?: string) { + this.sessions.clear(); + for (const row of rows) this.sessions.set(row.sessionId, row); + this.schedules.clear(); + for (const schedule of schedules) this.schedules.set(schedule.id, schedule); + if (scheduleTimeZone) this.scheduleTimeZone = scheduleTimeZone; this.emit(); } applySse(type: string, body: any) { @@ -89,28 +89,95 @@ class Store { export const store = new Store(); export async function bootstrap() { - const [s, sch] = await Promise.all([ - fetch('/api/sessions').then(r => r.json()), - fetch('/api/schedules').then(r => r.json()), - ]); - store.upsertSessions(s.sessions ?? []); - store.upsertSchedules(sch.schedules ?? []); - if (typeof sch.timezone === 'string') store.setScheduleTimeZone(sch.timezone); - + // Establish SSE before fetching snapshots, then buffer events while each + // authoritative snapshot is installed. Waiting for `open` matters: merely + // constructing EventSource does not mean the server listener exists yet. + const buffered: Array<{ type: string; body: any }> = []; + let snapshotReady = false; const es = new EventSource('/events'); const types = [ 'session.spawned', 'session.update', 'session.exited', 'schedule.created', 'schedule.updated', 'schedule.deleted', 'schedule.fired', 'schedule.timezone', 'heartbeat', ]; - for (const t of types) { - es.addEventListener(t, e => { + for (const type of types) { + es.addEventListener(type, e => { try { const data = JSON.parse((e as MessageEvent).data); - store.applySse(t, data.body ?? data); + const body = data.body ?? data; + if (snapshotReady) store.applySse(type, body); + else buffered.push({ type, body }); } catch { /* skip malformed */ } }); } + + let syncInFlight: Promise | null = null; + let requestedReconcile = 0; + let completedReconcile = 0; + const reconcileSnapshot = (): Promise => { + requestedReconcile += 1; + if (syncInFlight) return syncInFlight; + syncInFlight = (async () => { + while (completedReconcile < requestedReconcile) { + const generation = requestedReconcile; + snapshotReady = false; + try { + const [s, sch] = await Promise.all([ + fetch('/api/sessions').then(r => r.json()), + fetch('/api/schedules').then(r => r.json()), + ]); + store.replaceSnapshot( + s.sessions ?? [], + sch.schedules ?? [], + typeof sch.timezone === 'string' ? sch.timezone : undefined, + ); + completedReconcile = generation; + } finally { + snapshotReady = true; + for (const event of buffered.splice(0)) { + store.applySse(event.type, event.body); + } + } + } + })().finally(() => { + syncInFlight = null; + }); + return syncInFlight; + }; + + let firstOpen = true; + let resolveFirstOpen!: () => void; + let rejectFirstOpen!: (error: Error) => void; + const opened = new Promise((resolve, reject) => { + resolveFirstOpen = resolve; + rejectFirstOpen = reject; + }); + const openTimer = setTimeout(() => { + rejectFirstOpen(new Error('dashboard event stream open timed out')); + }, 10_000); es.onerror = () => store.setOnline(false); - es.onopen = () => store.setOnline(true); + es.onopen = () => { + store.setOnline(true); + if (firstOpen) { + firstOpen = false; + clearTimeout(openTimer); + resolveFirstOpen(); + return; + } + // EventSource reconnects automatically. Reconcile instead of replaying + // every historical row so changes from the disconnected window converge + // with one render, including deletes and closed sessions. + void reconcileSnapshot().catch(() => { + // The live stream remains useful; the next reconnect retries the snapshot. + }); + }; + + try { + await opened; + await reconcileSnapshot(); + } catch (error) { + clearTimeout(openTimer); + es.close(); + throw error; + } } diff --git a/src/worker.ts b/src/worker.ts index dca87ec3f..9ae6dfe6b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -7816,9 +7816,12 @@ function startWebServer(host: string, preferredPort?: number): Promise { const startAttach = (cols: number, rows: number) => { if (cp) return; // Why: a prior web resize may leave the shared tmux window in manual mode. - spawnSync('tmux', ['set-option', '-t', tmuxTarget, 'window-size', 'latest'], { + // `largest` restores the shared default without letting a smaller/newer + // viewer resize every other attached client. + spawnSync('tmux', ['set-option', '-t', tmuxTarget, 'window-size', 'largest'], { stdio: 'ignore', env: tmuxEnv() as { [key: string]: string }, + timeout: 3000, }); // Defense in depth: view-capability clients attach through tmux's // own read-only mode as well as the WebSocket input gate below. diff --git a/test/dashboard-aggregator.test.ts b/test/dashboard-aggregator.test.ts index 7b504e8a5..0eed027fe 100644 --- a/test/dashboard-aggregator.test.ts +++ b/test/dashboard-aggregator.test.ts @@ -50,6 +50,65 @@ describe('Aggregator cache merge', () => { expect(a.getSchedules().length).toBe(1); }); + it('preserves presentation fields when a daemon replays the same working directory', () => { + const a = new Aggregator(); + a.hydrateSessions('appA', [{ + sessionId: 's1', + larkAppId: 'appA', + workingDir: '/repo/a', + botAvatarUrl: 'https://img.example/a.png', + repoName: 'a', + gitBranch: 'main', + }]); + const seen: any[] = []; + a.on(event => seen.push(event)); + + a.applyEvent('appA', { + type: 'session.spawned', + body: { session: { sessionId: 's1', workingDir: '/repo/a', status: 'idle' } as any }, + }); + + expect(a.getSession('s1')).toMatchObject({ + botAvatarUrl: 'https://img.example/a.png', + repoName: 'a', + gitBranch: 'main', + status: 'idle', + }); + expect(seen[0].body.session).toMatchObject({ + repoName: 'a', + gitBranch: 'main', + }); + }); + + it('clears stale repository fields immediately when workingDir changes', () => { + const a = new Aggregator(); + a.hydrateSessions('appA', [{ + sessionId: 's1', + larkAppId: 'appA', + workingDir: '/repo/a', + repoName: 'a', + gitBranch: 'main', + }]); + const seen: any[] = []; + a.on(event => seen.push(event)); + + a.applyEvent('appA', { + type: 'session.update', + body: { sessionId: 's1', patch: { workingDir: '/repo/b' } }, + }); + + expect(a.getSession('s1')).toMatchObject({ + workingDir: '/repo/b', + repoName: null, + gitBranch: null, + }); + expect(seen[0].body.patch).toMatchObject({ + workingDir: '/repo/b', + repoName: null, + gitBranch: null, + }); + }); + it('ownerOf returns larkAppId for known sessionId', () => { const a = new Aggregator(); a.applyEvent('appA', { diff --git a/test/dashboard-desktop-asks.test.ts b/test/dashboard-desktop-asks.test.ts new file mode 100644 index 000000000..087b7329b --- /dev/null +++ b/test/dashboard-desktop-asks.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + parseDashboardAskAnswerRequest, + proxyDashboardAskAnswer, +} from '../src/dashboard/desktop-asks.js'; + +describe('desktop ask dashboard routing', () => { + it('requires a concrete daemon target and validates selections deeply', () => { + expect(parseDashboardAskAnswerRequest({ + askId: 'ask-1', + selections: [['yes']], + })).toEqual({ + ok: false, + error: 'askId_larkAppId_and_selections_required', + }); + expect(parseDashboardAskAnswerRequest({ + askId: 'ask-1', + larkAppId: 'app-a', + selections: [1], + })).toEqual({ ok: false, error: 'invalid_selections' }); + expect(parseDashboardAskAnswerRequest({ + askId: 'ask-1', + larkAppId: 'app-a', + selections: [['yes', 1]], + })).toEqual({ ok: false, error: 'invalid_selections' }); + }); + + it('proxies exactly once to the selected daemon and preserves upstream status', async () => { + const proxy = vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'already_settled' }), + { status: 409, headers: { 'content-type': 'application/json' } }, + )); + const result = await proxyDashboardAskAnswer({ + askId: 'ask-1', + larkAppId: 'app-b', + selections: [['yes']], + }, proxy); + + expect(proxy).toHaveBeenCalledTimes(1); + expect(proxy).toHaveBeenCalledWith( + 'app-b', + '/api/asks/answer', + expect.objectContaining({ method: 'POST' }), + ); + expect(result.status).toBe(409); + expect(JSON.parse(result.body)).toMatchObject({ error: 'already_settled' }); + }); + + it('maps an unavailable selected daemon to 503 without trying another daemon', async () => { + const proxy = vi.fn(async () => { + throw new Error('offline'); + }); + const result = await proxyDashboardAskAnswer({ + askId: 'ask-1', + larkAppId: 'app-offline', + selections: [['yes']], + }, proxy); + + expect(proxy).toHaveBeenCalledTimes(1); + expect(result.status).toBe(503); + expect(JSON.parse(result.body)).toEqual({ ok: false, error: 'daemon_unavailable' }); + }); +}); diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index bad81e336..4333e8b82 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -17,6 +17,12 @@ import { __testOnly_resetBotRegistry, loadBotConfigs, registerBot } from '../src import { config } from '../src/config.js'; import { sessionKey } from '../src/core/types.js'; import { writeRoleFile, writeTeamRoleFile } from '../src/core/role-resolver.js'; +import { + _allAskIds, + _resetForTest as resetAskBrokerForTest, + registerAsk, + setCardDispatcher, +} from '../src/core/ask-broker.js'; // Loopback-HMAC the write-link routes require. Inject a known secret per test // (setIpcAuthSecret) and sign with it, so the suite doesn't depend on a real @@ -101,6 +107,7 @@ afterEach(async () => { setLarkAppId(''); __testOnly_resetBotRegistry(); setIpcAuthSecret(null); + resetAskBrokerForTest(); }); describe('dashboard IPC server', () => { @@ -169,6 +176,106 @@ describe('dashboard IPC server', () => { }); }); +describe('Desktop ask IPC', () => { + it('keeps pending asks behind the trusted-host boundary', async () => { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + const pending = await fetch(`${base}/api/asks/pending`); + expect(pending.status).toBe(403); + expect(await pending.json()).toEqual({ ok: false, error: 'trusted_host_required' }); + + const answer = await fetch(`${base}/api/asks/answer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ askId: 'unknown', selections: [['yes']] }), + }); + expect(answer.status).toBe(403); + expect(await answer.json()).toEqual({ ok: false, error: 'trusted_host_required' }); + }); + + it('lists and answers only the selected daemon ask with validated selections', async () => { + setCardDispatcher({ send: async () => ({ messageId: 'om_dashboard_ask' }) }); + const result = registerAsk({ + larkAppId: 'app-one', + chatId: 'oc-chat', + rootMessageId: 'om-root', + sessionId: 'session-one', + questions: [{ + prompt: '继续吗?', + options: [ + { key: 'yes', label: '继续' }, + { key: 'no', label: '停止' }, + ], + multiSelect: false, + }], + timeoutMs: 30_000, + }); + const [askId] = _allAskIds(); + expect(askId).toBeTruthy(); + + setIpcAuthSecret(TEST_IPC_SECRET); + handle = await startIpcServer({ + port: 0, + host: '127.0.0.1', + authRequired: true, + }); + const base = `http://127.0.0.1:${handle.port}`; + + const pendingPath = '/api/asks/pending'; + const pending = await fetch(`${base}${pendingPath}`, { + headers: trustedHostHeaders('GET', pendingPath, handle.port), + }); + expect(pending.status).toBe(200); + expect(await pending.json()).toMatchObject({ + asks: [{ + askId, + sessionId: 'session-one', + larkAppId: 'app-one', + }], + }); + + const answerPath = '/api/asks/answer'; + const invalid = await fetch(`${base}${answerPath}`, { + method: 'POST', + headers: { + ...trustedHostHeaders('POST', answerPath, handle.port), + 'content-type': 'application/json', + }, + body: JSON.stringify({ askId, selections: [[]] }), + }); + expect(invalid.status).toBe(409); + expect(await invalid.json()).toEqual({ ok: false, error: 'stale' }); + + const accepted = await fetch(`${base}${answerPath}`, { + method: 'POST', + headers: { + ...trustedHostHeaders('POST', answerPath, handle.port), + 'content-type': 'application/json', + }, + body: JSON.stringify({ askId, selections: [['yes']], by: 'desktop' }), + }); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toEqual({ ok: true, outcome: 'accepted' }); + await expect(result).resolves.toMatchObject({ + kind: 'answered', + answers: [['yes']], + by: 'desktop', + }); + + const duplicate = await fetch(`${base}${answerPath}`, { + method: 'POST', + headers: { + ...trustedHostHeaders('POST', answerPath, handle.port), + 'content-type': 'application/json', + }, + body: JSON.stringify({ askId, selections: [['yes']] }), + }); + expect(duplicate.status).toBe(409); + expect(await duplicate.json()).toEqual({ ok: false, error: 'already_settled' }); + }); +}); + describe('PUT /api/bot-card-prefs — Codex App clean history', () => { it('is default-off and persists explicit on/off changes immediately', async () => { const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-codex-clean-')); diff --git a/test/dashboard-public-redact.test.ts b/test/dashboard-public-redact.test.ts index fd1c764fd..0e4b15941 100644 --- a/test/dashboard-public-redact.test.ts +++ b/test/dashboard-public-redact.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { redactGroupsForPublic, redactSchedulesForPublic } from '../src/dashboard/public-redact.js'; +import { + redactGroupsForPublic, + redactSchedulesForPublic, + redactSessionEventForPublic, + redactSessionsForPublic, +} from '../src/dashboard/public-redact.js'; // A representative slice of the /api/groups `chats` payload that dashboard.ts // builds (memberBots[].oncallChat = { chatId, workingDir } for bound bots). @@ -128,3 +133,38 @@ describe('redactSchedulesForPublic', () => { expect(redactSchedulesForPublic(undefined as unknown as unknown[])).toBeUndefined(); }); }); + +describe('session presentation redaction', () => { + const session = { + sessionId: 's1', + workingDir: '/repo/customer-a', + repoName: 'customer-a', + gitBranch: 'issue/CUSTOMER-123', + botAvatarUrl: 'https://img.example/bot.png', + }; + + it('strips branch names from anonymous REST rows without mutating authenticated data', () => { + const out = redactSessionsForPublic([session]) as any[]; + expect(out[0]).toMatchObject({ + sessionId: 's1', + workingDir: '/repo/customer-a', + repoName: 'customer-a', + botAvatarUrl: 'https://img.example/bot.png', + }); + expect(out[0]).not.toHaveProperty('gitBranch'); + expect(session.gitBranch).toBe('issue/CUSTOMER-123'); + }); + + it('applies the same policy to spawned and update SSE bodies', () => { + const spawned = redactSessionEventForPublic('session.spawned', { session }) as any; + expect(spawned.session).not.toHaveProperty('gitBranch'); + + const updateBody = { + sessionId: 's1', + patch: { gitBranch: 'issue/CUSTOMER-456', repoName: 'customer-a' }, + }; + const updated = redactSessionEventForPublic('session.update', updateBody) as any; + expect(updated.patch).toEqual({ repoName: 'customer-a' }); + expect(updateBody.patch.gitBranch).toBe('issue/CUSTOMER-456'); + }); +}); diff --git a/test/dashboard-session-presentation.test.ts b/test/dashboard-session-presentation.test.ts new file mode 100644 index 000000000..fbd422bda --- /dev/null +++ b/test/dashboard-session-presentation.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { Aggregator } from '../src/dashboard/aggregator.js'; +import { createSessionPresentationCoordinator } from '../src/dashboard/session-presentation.js'; + +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('dashboard session presentation coordinator', () => { + it('adds repository metadata to a newly spawned row through session.update', async () => { + const aggregator = new Aggregator(); + const coordinator = createSessionPresentationCoordinator( + aggregator, + async () => ({ repoName: 'botmux', branch: 'feat/dashboard' }), + ); + aggregator.on(coordinator.onEvent); + + aggregator.applyEvent('appA', { + type: 'session.spawned', + body: { session: { sessionId: 's1', workingDir: '/repo/botmux' } as any }, + }); + await settle(); + + expect(aggregator.getSession('s1')).toMatchObject({ + repoName: 'botmux', + gitBranch: 'feat/dashboard', + }); + }); + + it('drops a stale probe result after the working directory changes', async () => { + const aggregator = new Aggregator(); + let resolveFirst!: (value: { repoName: string; branch: string } | null) => void; + const first = new Promise<{ repoName: string; branch: string } | null>( + resolve => { resolveFirst = resolve; }, + ); + const coordinator = createSessionPresentationCoordinator( + aggregator, + workingDir => workingDir === '/repo/a' + ? first + : Promise.resolve({ repoName: 'b', branch: 'main' }), + ); + aggregator.on(coordinator.onEvent); + + aggregator.applyEvent('appA', { + type: 'session.spawned', + body: { session: { sessionId: 's1', workingDir: '/repo/a' } as any }, + }); + aggregator.applyEvent('appA', { + type: 'session.update', + body: { sessionId: 's1', patch: { workingDir: '/repo/b' } }, + }); + resolveFirst({ repoName: 'a', branch: 'old' }); + await settle(); + + expect(aggregator.getSession('s1')).toMatchObject({ + workingDir: '/repo/b', + repoName: 'b', + gitBranch: 'main', + }); + }); + + it('clears stale repository metadata when the directory is not a Git repo', async () => { + const aggregator = new Aggregator(); + const coordinator = createSessionPresentationCoordinator(aggregator, async () => null); + aggregator.on(coordinator.onEvent); + aggregator.hydrateSessions('appA', [{ + sessionId: 's1', + larkAppId: 'appA', + workingDir: '/plain', + repoName: 'old', + gitBranch: 'old', + }]); + + coordinator.schedule('appA', aggregator.getSession('s1')!); + await settle(); + + expect(aggregator.getSession('s1')).toMatchObject({ + repoName: null, + gitBranch: null, + }); + }); + + it('force-refreshes the branch at the idle turn boundary', async () => { + const aggregator = new Aggregator(); + let branch = 'main'; + let cached: { repoName: string; branch: string } | null = null; + const coordinator = createSessionPresentationCoordinator( + aggregator, + async (_workingDir, options) => { + if (!options?.force && cached) return cached; + cached = { repoName: 'botmux', branch }; + return cached; + }, + ); + aggregator.on(coordinator.onEvent); + + aggregator.applyEvent('appA', { + type: 'session.spawned', + body: { + session: { + sessionId: 's1', + workingDir: '/repo/botmux', + status: 'working', + } as any, + }, + }); + await settle(); + expect(aggregator.getSession('s1')?.gitBranch).toBe('main'); + + branch = 'feat/new-branch'; + aggregator.applyEvent('appA', { + type: 'session.update', + body: { sessionId: 's1', patch: { lastMessageAt: Date.now() } }, + }); + await settle(); + expect(aggregator.getSession('s1')?.gitBranch).toBe('main'); + + aggregator.applyEvent('appA', { + type: 'session.update', + body: { sessionId: 's1', patch: { status: 'idle', lastMessageAt: Date.now() } }, + }); + await settle(); + expect(aggregator.getSession('s1')?.gitBranch).toBe('feat/new-branch'); + }); + + it('does not let an older same-directory probe overwrite an idle refresh', async () => { + const aggregator = new Aggregator(); + let resolveOld!: (value: { repoName: string; branch: string }) => void; + const old = new Promise<{ repoName: string; branch: string }>( + resolve => { resolveOld = resolve; }, + ); + const coordinator = createSessionPresentationCoordinator( + aggregator, + async (_workingDir, options) => options?.force + ? { repoName: 'botmux', branch: 'feat/new-branch' } + : old, + ); + aggregator.on(coordinator.onEvent); + + aggregator.applyEvent('appA', { + type: 'session.spawned', + body: { + session: { + sessionId: 's1', + workingDir: '/repo/botmux', + status: 'working', + } as any, + }, + }); + aggregator.applyEvent('appA', { + type: 'session.update', + body: { sessionId: 's1', patch: { status: 'idle' } }, + }); + await settle(); + resolveOld({ repoName: 'botmux', branch: 'main' }); + await settle(); + + expect(aggregator.getSession('s1')?.gitBranch).toBe('feat/new-branch'); + }); +}); diff --git a/test/dashboard-store-bootstrap.test.ts b/test/dashboard-store-bootstrap.test.ts new file mode 100644 index 000000000..e44213b27 --- /dev/null +++ b/test/dashboard-store-bootstrap.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type Listener = (event: { data: string }) => void; + +class FakeEventSource { + static instance: FakeEventSource | null = null; + readonly listeners = new Map(); + onerror: (() => void) | null = null; + onopen: (() => void) | null = null; + closed = false; + + constructor(readonly url: string) { + FakeEventSource.instance = this; + } + + addEventListener(type: string, listener: Listener): void { + this.listeners.set(type, listener); + } + + emit(type: string, body: unknown): void { + this.listeners.get(type)?.({ + data: JSON.stringify({ body }), + }); + } + + open(): void { + this.onopen?.(); + } + + close(): void { + this.closed = true; + } +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + return { + promise: new Promise(done => { resolve = done; }), + resolve, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + FakeEventSource.instance = null; +}); + +describe('dashboard store bootstrap', () => { + it('waits for SSE open, buffers snapshot races, and reconciles reconnect gaps', async () => { + const initialSessions = deferred(); + const initialSchedules = deferred(); + const reconnectSessions = deferred(); + const reconnectSchedules = deferred(); + const sessionResponses = [initialSessions.promise, reconnectSessions.promise]; + const scheduleResponses = [initialSchedules.promise, reconnectSchedules.promise]; + vi.stubGlobal('EventSource', FakeEventSource); + const fetchMock = vi.fn((path: string) => ( + path === '/api/sessions' + ? sessionResponses.shift()! + : scheduleResponses.shift()! + )); + vi.stubGlobal('fetch', fetchMock); + const { bootstrap, store } = await import('../src/dashboard/web/store.js'); + + const boot = bootstrap(); + const events = FakeEventSource.instance; + expect(events?.url).toBe('/events'); + expect(fetchMock).not.toHaveBeenCalled(); + events?.open(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + events?.emit('session.spawned', { + session: { + sessionId: 'race-session', + status: 'idle', + repoName: 'botmux', + gitBranch: 'feat/live', + }, + }); + + initialSessions.resolve(new Response(JSON.stringify({ + sessions: [{ + sessionId: 'race-session', + status: 'working', + repoName: 'botmux', + gitBranch: 'main', + }, { + sessionId: 'removed-while-offline', + status: 'idle', + }], + }))); + initialSchedules.resolve(new Response(JSON.stringify({ + schedules: [{ id: 'deleted-schedule' }], + }))); + await boot; + + expect(store.sessions.get('race-session')).toMatchObject({ + status: 'idle', + gitBranch: 'feat/live', + }); + expect(store.sessions.has('removed-while-offline')).toBe(true); + expect(store.schedules.has('deleted-schedule')).toBe(true); + + events?.open(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + events?.emit('session.update', { + sessionId: 'race-session', + patch: { status: 'idle', gitBranch: 'feat/reconnected' }, + }); + reconnectSessions.resolve(new Response(JSON.stringify({ + sessions: [{ + sessionId: 'race-session', + status: 'working', + repoName: 'botmux', + gitBranch: 'main', + }], + }))); + reconnectSchedules.resolve(new Response(JSON.stringify({ schedules: [] }))); + + await vi.waitFor(() => { + expect(store.sessions.get('race-session')).toMatchObject({ + status: 'idle', + gitBranch: 'feat/reconnected', + }); + expect(store.sessions.has('removed-while-offline')).toBe(false); + expect(store.schedules.has('deleted-schedule')).toBe(false); + }); + }); +}); diff --git a/test/desktop/dashboard-client-shell.test.ts b/test/desktop/dashboard-client-shell.test.ts index e7a2454ad..5ade61cc1 100644 --- a/test/desktop/dashboard-client-shell.test.ts +++ b/test/desktop/dashboard-client-shell.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + canonicalDashboardClientShellUrl, dashboardClientShellRedirect, dashboardShellAllowsWebTerminal, isWebTerminalDashboardHash, @@ -23,6 +24,25 @@ describe('dashboard client shell', () => { .toBeNull(); }); + it('canonicalizes a legacy hash marker into the durable URL query', () => { + expect(canonicalDashboardClientShellUrl( + 'https://botmux.example.test/#/sessions?botmuxClientShell=desktop&focus=ask-1', + )).toBe( + 'https://botmux.example.test/?botmuxClientShell=desktop#/sessions?focus=ask-1', + ); + expect(canonicalDashboardClientShellUrl( + 'https://botmux.example.test/?locale=zh#/sessions?botmuxClientShell=desktop&focus=ask-1', + )).toBe( + 'https://botmux.example.test/?locale=zh&botmuxClientShell=desktop#/sessions?focus=ask-1', + ); + expect(canonicalDashboardClientShellUrl( + 'https://botmux.example.test/?botmuxClientShell=mobile#/sessions', + )).toBeNull(); + expect(canonicalDashboardClientShellUrl( + 'https://botmux.example.test/#/sessions?botmuxClientShell=unknown', + )).toBeNull(); + }); + it('recognizes every legacy and current workflow route', () => { for (const hash of [ '#/workflows', diff --git a/test/desktop/dashboard-compat.test.ts b/test/desktop/dashboard-compat.test.ts index 75123f609..394cb44c1 100644 --- a/test/desktop/dashboard-compat.test.ts +++ b/test/desktop/dashboard-compat.test.ts @@ -2,7 +2,11 @@ import { readFileSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import { afterEach, describe, expect, it } from 'vitest'; -import { buildCompatManifest, handleDesktopCompat } from '../../src/dashboard/compat.js'; +import { + buildCompatManifest, + compatMachineIdForAuthenticatedRequest, + handleDesktopCompat, +} from '../../src/dashboard/compat.js'; let server: Server | null = null; @@ -18,7 +22,6 @@ describe('dashboard desktop compat manifest', () => { it('builds the v2 manifest while retaining the v1 compatibility fields', () => { const manifest = buildCompatManifest({ runtimeVersion: '2.95.0', - machineId: null, }); expect(manifest).toMatchObject({ @@ -53,6 +56,7 @@ describe('dashboard desktop compat manifest', () => { workflow: { supported: false }, }); expect(manifest.capabilities).toMatchObject({ + 'overview.read': true, 'sessions.read': true, 'sessions.manage': true, 'asks.answer': true, @@ -64,6 +68,11 @@ describe('dashboard desktop compat manifest', () => { 'workflow.manage': false, }); expect(manifest.routes).not.toContain('#/workflows'); + expect(manifest.routes).toEqual( + Object.values(manifest.modules) + .filter(module => module.supported && module.route) + .map(module => module.route), + ); }); it('only exposes a machine identity when a reliable id is supplied', () => { @@ -81,6 +90,29 @@ describe('dashboard desktop compat manifest', () => { }).runtimeIdentity).toBeUndefined(); }); + it('only releases the bound machine identity to the active dashboard token', () => { + expect(compatMachineIdForAuthenticatedRequest( + 'active-token', + 'active-token', + ' machine-123 ', + )).toBe('machine-123'); + expect(compatMachineIdForAuthenticatedRequest( + undefined, + 'active-token', + 'machine-123', + )).toBeNull(); + expect(compatMachineIdForAuthenticatedRequest( + 'stale-token', + 'active-token', + 'machine-123', + )).toBeNull(); + expect(compatMachineIdForAuthenticatedRequest( + 'active-token', + undefined, + 'machine-123', + )).toBeNull(); + }); + it('serves GET /__desktop/compat as read-only JSON', async () => { const started = await startCompatServer(); diff --git a/test/session-cwd.test.ts b/test/session-cwd.test.ts new file mode 100644 index 000000000..01db0dadb --- /dev/null +++ b/test/session-cwd.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { dashboardEventBus, type DashboardEvent } from '../src/core/dashboard-events.js'; +import { repinSessionWorkingDir } from '../src/core/session-cwd.js'; +import type { DaemonSession } from '../src/core/types.js'; +import * as sessionStore from '../src/services/session-store.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('repinSessionWorkingDir', () => { + it('publishes the canonical workingDir update after persisting it', () => { + const updateSession = vi.spyOn(sessionStore, 'updateSession').mockImplementation(() => {}); + const events: DashboardEvent[] = []; + const off = dashboardEventBus.subscribe(event => events.push(event)); + const ds = { + workingDir: '/repo/old', + session: { + sessionId: 'session-one', + workingDir: '/repo/old', + riffRepoDirs: ['/repo/old'], + }, + } as DaemonSession; + + try { + repinSessionWorkingDir(ds, '/repo/new'); + } finally { + off(); + } + + expect(ds.workingDir).toBe('/repo/new'); + expect(ds.session.workingDir).toBe('/repo/new'); + expect(ds.session.riffRepoDirs).toBeUndefined(); + expect(updateSession).toHaveBeenCalledWith(ds.session); + expect(events).toContainEqual({ + type: 'session.update', + body: { + sessionId: 'session-one', + patch: { workingDir: '/repo/new' }, + }, + }); + }); + + it('does not publish an in-memory cwd when persistence fails', () => { + vi.spyOn(sessionStore, 'updateSession').mockImplementation(() => { + throw new Error('disk unavailable'); + }); + const events: DashboardEvent[] = []; + const off = dashboardEventBus.subscribe(event => events.push(event)); + const ds = { + workingDir: '/repo/old', + session: { sessionId: 'session-one', workingDir: '/repo/old' }, + } as DaemonSession; + + try { + expect(() => repinSessionWorkingDir(ds, '/repo/new')).toThrow('disk unavailable'); + } finally { + off(); + } + + expect(events).toEqual([]); + }); +}); diff --git a/test/session-row-enrichment.test.ts b/test/session-row-enrichment.test.ts index 614b42702..05672948e 100644 --- a/test/session-row-enrichment.test.ts +++ b/test/session-row-enrichment.test.ts @@ -1,12 +1,10 @@ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { clearSessionRowEnrichmentCaches, - enrichSessionRowsForPresentation, - getBotAvatarByAppId, getGitRepoInfo, } from '../src/core/session-row-enrichment.js'; @@ -35,31 +33,6 @@ afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); -describe('getBotAvatarByAppId', () => { - it('maps larkAppId → avatar URL from bots-info.json', () => { - const dataDir = tempDir('botmux-enrich-data-'); - writeFileSync( - join(dataDir, 'bots-info.json'), - JSON.stringify([ - { larkAppId: 'cli_a', botAvatarUrl: 'https://img.example/a.png' }, - { larkAppId: 'cli_b', botAvatarUrl: null }, - { larkAppId: 'cli_c' }, - ]), - ); - const map = getBotAvatarByAppId(dataDir); - expect(map.get('cli_a')).toBe('https://img.example/a.png'); - expect(map.has('cli_b')).toBe(false); - expect(map.has('cli_c')).toBe(false); - }); - - it('returns an empty map when bots-info.json is missing or corrupt', () => { - const dataDir = tempDir('botmux-enrich-data-'); - expect(getBotAvatarByAppId(dataDir).size).toBe(0); - writeFileSync(join(dataDir, 'bots-info.json'), '{nope'); - expect(getBotAvatarByAppId(dataDir).size).toBe(0); - }); -}); - describe('getGitRepoInfo', () => { it('resolves repo name + branch for a git workdir', async () => { const repo = initRepo('feat/enrich-x'); @@ -85,6 +58,16 @@ describe('getGitRepoInfo', () => { expect(info?.branch).toBeNull(); }); + it('force-refreshes a branch change inside the positive cache TTL', async () => { + const repo = initRepo('main'); + expect((await getGitRepoInfo(repo))?.branch).toBe('main'); + git(['checkout', '-q', '-b', 'feat/live'], repo); + + expect((await getGitRepoInfo(repo))?.branch).toBe('main'); + expect((await getGitRepoInfo(repo, { force: true }))?.branch).toBe('feat/live'); + expect((await getGitRepoInfo(repo))?.branch).toBe('feat/live'); + }); + it('returns null for non-repo dirs and caches the miss', async () => { const plain = tempDir('botmux-enrich-plain-'); expect(await getGitRepoInfo(plain)).toBeNull(); @@ -97,28 +80,3 @@ describe('getGitRepoInfo', () => { expect(await getGitRepoInfo(' ')).toBeNull(); }); }); - -describe('enrichSessionRowsForPresentation', () => { - it('stamps avatar + repo/branch; passes untouched rows through by identity', async () => { - const dataDir = tempDir('botmux-enrich-data-'); - writeFileSync( - join(dataDir, 'bots-info.json'), - JSON.stringify([{ larkAppId: 'cli_a', botAvatarUrl: 'https://img.example/a.png' }]), - ); - const repo = initRepo('main'); - const plain = tempDir('botmux-enrich-plain-'); - - const rich = { sessionId: 's1', larkAppId: 'cli_a', workingDir: repo }; - const plainRow = { sessionId: 's2', larkAppId: 'cli_zzz', workingDir: plain }; - const bare = { sessionId: 's3' }; - const [r1, r2, r3] = await enrichSessionRowsForPresentation([rich, plainRow, bare], dataDir); - - expect(r1.botAvatarUrl).toBe('https://img.example/a.png'); - expect(r1.repoName).toBe(repo.split('/').pop()); - expect(r1.gitBranch).toBe('main'); - expect(r2.botAvatarUrl).toBeUndefined(); - expect(r2.repoName).toBeUndefined(); - expect(r2).toBe(plainRow); - expect(r3).toBe(bare); - }); -}); diff --git a/test/web-terminal-tmux-window-size.test.ts b/test/web-terminal-tmux-window-size.test.ts deleted file mode 100644 index 9dea2ce54..000000000 --- a/test/web-terminal-tmux-window-size.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const workerSource = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8') - -describe('web terminal tmux attach sizing', () => { - it('restores responsive tmux sizing before spawning the phone-sized attach', () => { - const attachBlockStart = workerSource.indexOf('const startAttach = (cols: number, rows: number)') - const attachBlockEnd = workerSource.indexOf("cp = pty.spawn('tmux'", attachBlockStart) - expect(attachBlockStart).toBeGreaterThan(-1) - expect(attachBlockEnd).toBeGreaterThan(attachBlockStart) - - const beforeSpawn = workerSource.slice(attachBlockStart, attachBlockEnd) - expect(beforeSpawn).toContain("['set-option', '-t', tmuxTarget, 'window-size', 'latest']") - }) -}) From 007386feacdafa588d84c95853008ac053028156 Mon Sep 17 00:00:00 2001 From: "tengpengfei.tpf" Date: Mon, 27 Jul 2026 21:56:04 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(dashboard):=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AE=A9=20SSE=20=E5=BB=BA=E8=BF=9E=E5=8D=A1=E4=BD=8F=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap() 之前是「建 EventSource → 等 onopen(10s 超时)→ 才拉快照」, 失败路径还 es.close()。SSE 经反代(nginx 默认 proxy_buffering on)或平台隧道 被缓冲时,快照根本不会去拉,页面停在空列表;close() 又掐掉了 EventSource 自带的重连,用户不手动刷新就再也回不来——把「丢实时更新」的降级放大成了全挂。 改成:首屏快照立刻拉,不等 onopen;改为在每次 open(含第一次)上做一次 reconcile。第一次也要 reconcile 是因为 new EventSource 不等于服务端监听已 建立,先于订阅生成的快照可能漏掉窗口期的事件——这正是原来等 open 想保证的 性质,用 reconcile 换个方式拿回来。失败时保留流不关,交给 EventSource 自己 重连,open 处理器负责恢复。代价是首屏多一轮快照请求。 顺带两处: - syncSubscriptions 的头像回填双向生效,bot 清空头像时不再残留旧图。 - 修正 session-presentation 的注释:idle/limited 边界的刷新是每会话每轮一次 git rev-parse(有并发上限兜底),不是 "low-frequency" 的后台轮询。 --- src/dashboard.ts | 23 +++++---- src/dashboard/web/store.ts | 44 ++++++----------- test/dashboard-store-bootstrap.test.ts | 68 ++++++++++++++++++++------ 3 files changed, 81 insertions(+), 54 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index 9a127ef19..efede5beb 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -303,8 +303,10 @@ const aggregator = new Aggregator(); const sessionPresentation = createSessionPresentationCoordinator(aggregator, getGitRepoInfo); // Keep Git-derived fields in the central read-model so REST snapshots and SSE -// share one row shape. Idle/limited turn boundaries force a low-frequency -// branch refresh after the CLI has had a chance to change repositories. +// share one row shape. Idle/limited turn boundaries force a branch refresh +// after the CLI has had a chance to change repositories — that is one +// `git rev-parse` per session per turn, bounded by the resolver's concurrency +// cap, NOT a slow background poll. aggregator.on(sessionPresentation.onEvent); // 调试终端(owner-only 裸 bash)。默认工作目录取当前所有 session 的工作目录去重, @@ -1306,14 +1308,15 @@ function syncSubscriptions(): void { if (!subs.has(d.larkAppId)) { void attachDaemon(d); } - if (d.botAvatarUrl) { - for (const row of aggregator.getSessions()) { - if (row.larkAppId !== d.larkAppId || row.botAvatarUrl === d.botAvatarUrl) continue; - aggregator.applyEvent(d.larkAppId, { - type: 'session.update', - body: { sessionId: row.sessionId, patch: { botAvatarUrl: d.botAvatarUrl } }, - }); - } + // Push avatar changes in BOTH directions: gating on `d.botAvatarUrl` would + // leave the stale image on every row when a bot's avatar is cleared. + const avatar = d.botAvatarUrl ?? null; + for (const row of aggregator.getSessions()) { + if (row.larkAppId !== d.larkAppId || (row.botAvatarUrl ?? null) === avatar) continue; + aggregator.applyEvent(d.larkAppId, { + type: 'session.update', + body: { sessionId: row.sessionId, patch: { botAvatarUrl: avatar } }, + }); } } // Close subscriptions for daemons that went offline. Cache entries are diff --git a/src/dashboard/web/store.ts b/src/dashboard/web/store.ts index 48e58686f..10ea98f36 100644 --- a/src/dashboard/web/store.ts +++ b/src/dashboard/web/store.ts @@ -100,8 +100,7 @@ export const store = new Store(); export async function bootstrap() { // Establish SSE before fetching snapshots, then buffer events while each - // authoritative snapshot is installed. Waiting for `open` matters: merely - // constructing EventSource does not mean the server listener exists yet. + // authoritative snapshot is installed. const buffered: Array<{ type: string; body: any }> = []; let snapshotReady = false; const es = new EventSource('/events'); @@ -155,39 +154,24 @@ export async function bootstrap() { return syncInFlight; }; - let firstOpen = true; - let resolveFirstOpen!: () => void; - let rejectFirstOpen!: (error: Error) => void; - const opened = new Promise((resolve, reject) => { - resolveFirstOpen = resolve; - rejectFirstOpen = reject; - }); - const openTimer = setTimeout(() => { - rejectFirstOpen(new Error('dashboard event stream open timed out')); - }, 10_000); es.onerror = () => store.setOnline(false); es.onopen = () => { store.setOnline(true); - if (firstOpen) { - firstOpen = false; - clearTimeout(openTimer); - resolveFirstOpen(); - return; - } - // EventSource reconnects automatically. Reconcile instead of replaying - // every historical row so changes from the disconnected window converge - // with one render, including deletes and closed sessions. + // Reconcile on EVERY open, first one included. Constructing an EventSource + // does not mean the server-side listener exists yet, so a snapshot taken + // before this point can miss whatever happened in that window; a reconnect + // can additionally miss deletes, which only a fresh snapshot converges. + // reconcileSnapshot coalesces, so overlapping calls collapse into one extra + // round instead of a fetch per event. void reconcileSnapshot().catch(() => { - // The live stream remains useful; the next reconnect retries the snapshot. + // The live stream remains useful; the next open retries the snapshot. }); }; - try { - await opened; - await reconcileSnapshot(); - } catch (error) { - clearTimeout(openTimer); - es.close(); - throw error; - } + // Never gate the first snapshot on `onopen`: a buffering reverse proxy can + // delay the stream indefinitely, and a board with slightly stale rows beats + // an empty one. On failure the stream is deliberately left open so + // EventSource keeps retrying on its own and the open handler above recovers + // without a manual refresh. + await reconcileSnapshot(); } diff --git a/test/dashboard-store-bootstrap.test.ts b/test/dashboard-store-bootstrap.test.ts index e44213b27..9e9217a37 100644 --- a/test/dashboard-store-bootstrap.test.ts +++ b/test/dashboard-store-bootstrap.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; type Listener = (event: { data: string }) => void; @@ -43,20 +43,28 @@ function deferred(): { }; } +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body)); +} + +beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('EventSource', FakeEventSource); +}); + afterEach(() => { vi.unstubAllGlobals(); FakeEventSource.instance = null; }); describe('dashboard store bootstrap', () => { - it('waits for SSE open, buffers snapshot races, and reconciles reconnect gaps', async () => { + it('fetches the snapshot without waiting for SSE open, then buffers races and reconciles on open', async () => { const initialSessions = deferred(); const initialSchedules = deferred(); const reconnectSessions = deferred(); const reconnectSchedules = deferred(); const sessionResponses = [initialSessions.promise, reconnectSessions.promise]; const scheduleResponses = [initialSchedules.promise, reconnectSchedules.promise]; - vi.stubGlobal('EventSource', FakeEventSource); const fetchMock = vi.fn((path: string) => ( path === '/api/sessions' ? sessionResponses.shift()! @@ -65,12 +73,15 @@ describe('dashboard store bootstrap', () => { vi.stubGlobal('fetch', fetchMock); const { bootstrap, store } = await import('../src/dashboard/web/store.js'); + // A buffering reverse proxy can hold `onopen` back indefinitely. The board + // must still load, so the snapshot request goes out immediately. const boot = bootstrap(); const events = FakeEventSource.instance; expect(events?.url).toBe('/events'); - expect(fetchMock).not.toHaveBeenCalled(); - events?.open(); - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Events that land while the snapshot is in flight are newer than it, so + // they must be replayed on top instead of being lost to `replaceSnapshot`. events?.emit('session.spawned', { session: { sessionId: 'race-session', @@ -80,7 +91,7 @@ describe('dashboard store bootstrap', () => { }, }); - initialSessions.resolve(new Response(JSON.stringify({ + initialSessions.resolve(jsonResponse({ sessions: [{ sessionId: 'race-session', status: 'working', @@ -90,10 +101,8 @@ describe('dashboard store bootstrap', () => { sessionId: 'removed-while-offline', status: 'idle', }], - }))); - initialSchedules.resolve(new Response(JSON.stringify({ - schedules: [{ id: 'deleted-schedule' }], - }))); + })); + initialSchedules.resolve(jsonResponse({ schedules: [{ id: 'deleted-schedule' }] })); await boot; expect(store.sessions.get('race-session')).toMatchObject({ @@ -103,21 +112,23 @@ describe('dashboard store bootstrap', () => { expect(store.sessions.has('removed-while-offline')).toBe(true); expect(store.schedules.has('deleted-schedule')).toBe(true); + // The first open counts too: the snapshot above may predate the server-side + // subscription, so only a fresh snapshot converges deletes either way. events?.open(); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); events?.emit('session.update', { sessionId: 'race-session', patch: { status: 'idle', gitBranch: 'feat/reconnected' }, }); - reconnectSessions.resolve(new Response(JSON.stringify({ + reconnectSessions.resolve(jsonResponse({ sessions: [{ sessionId: 'race-session', status: 'working', repoName: 'botmux', gitBranch: 'main', }], - }))); - reconnectSchedules.resolve(new Response(JSON.stringify({ schedules: [] }))); + })); + reconnectSchedules.resolve(jsonResponse({ schedules: [] })); await vi.waitFor(() => { expect(store.sessions.get('race-session')).toMatchObject({ @@ -128,4 +139,33 @@ describe('dashboard store bootstrap', () => { expect(store.schedules.has('deleted-schedule')).toBe(false); }); }); + + it('keeps the stream open when the first snapshot fails so a later open recovers', async () => { + let sessionCalls = 0; + const fetchMock = vi.fn((path: string) => { + if (path === '/api/sessions') { + sessionCalls += 1; + return sessionCalls === 1 + ? Promise.reject(new Error('snapshot unavailable')) + : Promise.resolve(jsonResponse({ + sessions: [{ sessionId: 'recovered-session', status: 'idle' }], + })); + } + return Promise.resolve(jsonResponse({ schedules: [] })); + }); + vi.stubGlobal('fetch', fetchMock); + const { bootstrap, store } = await import('../src/dashboard/web/store.js'); + + await expect(bootstrap()).rejects.toThrow('snapshot unavailable'); + const events = FakeEventSource.instance; + // Closing here would kill EventSource's own retry and strand the page until + // a manual refresh. + expect(events?.closed).toBe(false); + expect(store.sessions.has('recovered-session')).toBe(false); + + events?.open(); + await vi.waitFor(() => { + expect(store.sessions.has('recovered-session')).toBe(true); + }); + }); }); From e2e2b77e030a951cd72b3749076f26e8c719c3a0 Mon Sep 17 00:00:00 2001 From: deepcoldy Date: Mon, 27 Jul 2026 18:04:58 +0000 Subject: [PATCH 6/6] =?UTF-8?q?test(terminal):=20=E8=A1=A5=E5=9B=9E=20tmux?= =?UTF-8?q?=20window-size=20=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20largest=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review PR #621 发现:window-size 从 latest 改成 largest 时(commit 3d6935fa), 配套的 test/web-terminal-tmux-window-size.test.ts 被一并删掉,导致该行改动零 测试覆盖。补回该文件并对齐当前实现——断言 `largest`(共享观看场景下不让新 attach 的小窗口缩小所有客户端)+ `timeout: 3000`(tmux 控制 socket 卡住时不 阻塞 attach)。 pnpm build ✅ / 该测试单跑 1 passed ✅ Co-Authored-By: Claude --- test/web-terminal-tmux-window-size.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 test/web-terminal-tmux-window-size.test.ts diff --git a/test/web-terminal-tmux-window-size.test.ts b/test/web-terminal-tmux-window-size.test.ts new file mode 100644 index 000000000..fc9baaca1 --- /dev/null +++ b/test/web-terminal-tmux-window-size.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const workerSource = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8') + +describe('web terminal tmux attach sizing', () => { + it('restores responsive tmux sizing before spawning the attach', () => { + const attachBlockStart = workerSource.indexOf('const startAttach = (cols: number, rows: number)') + const attachBlockEnd = workerSource.indexOf("cp = pty.spawn('tmux'", attachBlockStart) + expect(attachBlockStart).toBeGreaterThan(-1) + expect(attachBlockEnd).toBeGreaterThan(attachBlockStart) + + const beforeSpawn = workerSource.slice(attachBlockStart, attachBlockEnd) + // `largest` restores the shared default so a prior manual web resize does + // not strand the window in a fixed size — without letting a smaller/newer + // viewer shrink every other attached client (which `latest` would do). + expect(beforeSpawn).toContain("['set-option', '-t', tmuxTarget, 'window-size', 'largest']") + // Bounded so a wedged tmux control socket cannot block the attach forever. + expect(beforeSpawn).toContain('timeout: 3000') + }) +})