diff --git a/bridge/package-lock.json b/bridge/package-lock.json index e4a4569..310c19e 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "ftown-bridge", - "version": "0.19.6", + "version": "0.19.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ftown-bridge", - "version": "0.19.6", + "version": "0.19.7", "license": "MIT", "dependencies": { "@xterm/addon-serialize": "^0.14.0", diff --git a/bridge/package.json b/bridge/package.json index 30e06dd..f77fb33 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "ftown-bridge", - "version": "0.19.6", + "version": "0.19.7", "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay", "type": "module", "main": "dist/index.js", diff --git a/bridge/src/command-rpc.test.ts b/bridge/src/command-rpc.test.ts index 7faab24..987911d 100644 --- a/bridge/src/command-rpc.test.ts +++ b/bridge/src/command-rpc.test.ts @@ -53,3 +53,43 @@ test('update_session_parent rejects a missing or invalid parentSessionId', async }, ]); }); + +test('get_sessions_usage returns one id-keyed response for a session batch', async () => { + const responses: CommandResponse[] = []; + const calls: string[][] = []; + const usage = { + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: 3, + cacheWriteTokens: 4, + totalTokens: 10, + models: ['claude-sonnet-5'], + harness: 'claude', + collectedAt: '2026-08-07T00:00:00.000Z', + }; + const sessionController = { + usages: async (sessionIds: string[]) => { + calls.push(sessionIds); + return { 'session-1': usage }; + }, + } as unknown as SessionController; + const handler = createCommandHandler({ + bridgeId: 'bridge-1', + sessionController, + loopController: {} as LoopController, + publishCommandResponse: async (response) => { responses.push(response); }, + }); + + await handler({ + type: 'get_sessions_usage', + payload: { bridgeId: 'bridge-1', sessionIds: ['session-1', 'session-2'] }, + requestId: 'usage-batch-1', + } as unknown as Command); + + assert.deepEqual(calls, [['session-1', 'session-2']]); + assert.deepEqual(responses, [{ + requestId: 'usage-batch-1', + success: true, + data: { usages: { 'session-1': usage } }, + }]); +}); diff --git a/bridge/src/command-rpc.ts b/bridge/src/command-rpc.ts index de032a1..c8952aa 100644 --- a/bridge/src/command-rpc.ts +++ b/bridge/src/command-rpc.ts @@ -17,6 +17,7 @@ import type { GetHistoryPayload, GetLoopRunsPayload, GetSessionUsagePayload, + GetSessionsUsagePayload, RemoveSessionPayload, RenameSessionPayload, RunLoopNowPayload, @@ -234,6 +235,23 @@ export function createCommandHandler(deps: CommandRpcDeps): (command: Command) = break; } + case 'get_sessions_usage': { + const payload = command.payload as GetSessionsUsagePayload; + if ( + !Array.isArray(payload.sessionIds) + || payload.sessionIds.length === 0 + || payload.sessionIds.length > 200 + || payload.sessionIds.some((id) => typeof id !== 'string' || !id) + ) { + response = { requestId: command.requestId, success: false, error: 'Missing or invalid sessionIds' }; + break; + } + + const usages = await sessionController.usages(payload.sessionIds); + response = { requestId: command.requestId, success: true, data: { usages } }; + break; + } + case 'create_loop': { const payload = command.payload as CreateLoopPayload; // bridgeId is forced to THIS bridge inside the controller (the routing diff --git a/bridge/src/session-controller.test.ts b/bridge/src/session-controller.test.ts index 91b9aa3..023caac 100644 --- a/bridge/src/session-controller.test.ts +++ b/bridge/src/session-controller.test.ts @@ -285,6 +285,29 @@ describe('SessionController.usage', () => { assert.deepEqual(result, { ok: false, code: 'not_found', message: 'Session not found' }); }); + it('collects a deduplicated batch and omits sessions that no longer exist', async () => { + const first = makeUsage({ totalTokens: 11 }); + const second = makeUsage({ totalTokens: 22 }); + const collectorCalls: string[] = []; + const { controller } = setup( + [ + makeSession({ id: 'sess-1', status: 'running' }), + makeSession({ id: 'sess-2', status: 'running' }), + ], + { + collectUsage: async (session) => { + collectorCalls.push(session.id); + return session.id === 'sess-1' ? first : second; + }, + }, + ); + + const usages = await controller.usages(['sess-1', 'missing', 'sess-2', 'sess-1']); + + assert.deepEqual(usages, { 'sess-1': first, 'sess-2': second }); + assert.deepEqual(collectorCalls.sort(), ['sess-1', 'sess-2']); + }); + it('passes through persisted usage without invoking the collector', async () => { const persisted = makeUsage({ totalTokens: 42 }); let collectorCalls = 0; diff --git a/bridge/src/session-controller.ts b/bridge/src/session-controller.ts index be382d1..54fcf77 100644 --- a/bridge/src/session-controller.ts +++ b/bridge/src/session-controller.ts @@ -153,6 +153,17 @@ export class SessionController { return { ok: true, usage: usage ?? session.usage ?? null }; } + /** Collect several sessions behind one transport request. Missing sessions are omitted. */ + async usages(sessionIds: string[]): Promise> { + const entries = await Promise.all( + Array.from(new Set(sessionIds)).map(async (sessionId) => { + const result = await this.usage(sessionId); + return result.ok ? ([sessionId, result.usage] as const) : null; + }), + ); + return Object.fromEntries(entries.filter((entry) => entry !== null)); + } + /** Re-run a finished/dead session's stored command verbatim. */ async retry(sessionId: string): Promise> { const factory = this.require(this.deps.sessionFactory, 'sessionFactory'); diff --git a/bridge/src/types.ts b/bridge/src/types.ts index a133d5b..ffb8057 100644 --- a/bridge/src/types.ts +++ b/bridge/src/types.ts @@ -176,7 +176,7 @@ export interface Command { requestId: string; } -export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent' | 'get_session_usage' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs'; +export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent' | 'get_session_usage' | 'get_sessions_usage' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs'; export interface CreateSessionPayload { command: string; @@ -239,6 +239,11 @@ export interface GetSessionUsagePayload { bridgeId?: string; } +export interface GetSessionsUsagePayload { + sessionIds: string[]; + bridgeId?: string; +} + export interface CreateLoopPayload extends LoopDraft { bridgeId: string } export interface ListLoopsPayload { bridgeId?: string } export interface UpdateLoopPayload { bridgeId: string; loopId: string; patch: Partial } @@ -246,7 +251,7 @@ export interface DeleteLoopPayload { bridgeId: string; loopId: string } export interface RunLoopNowPayload { bridgeId: string; loopId: string } export interface GetLoopRunsPayload { bridgeId?: string; loopId: string } -export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | GetSessionUsagePayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record; +export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | GetSessionUsagePayload | GetSessionsUsagePayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record; export interface CommandResponse { requestId: string; diff --git a/docs/investigations/2026-08-07-live-usage-command-fanout.md b/docs/investigations/2026-08-07-live-usage-command-fanout.md new file mode 100644 index 0000000..854641a --- /dev/null +++ b/docs/investigations/2026-08-07-live-usage-command-fanout.md @@ -0,0 +1,155 @@ +--- +type: investigation +symptom: "Live session usage refresh emits a burst of get_session_usage commands" +slug: live-usage-command-fanout +date: 2026-08-07T05:19:34-03:00 +investigator: Foad Kesheh +git_commit: c1ea6231f90aca3bfdb8a9ab2acbfe4ef16bc86f +branch: fix/smart-usage-refresh +repository: fmktech/ftown +status: resolved +hypotheses_formed: 3 +hypotheses_rejected: 2 +hypotheses_proven: 1 +related: + - docs/investigations/2026-08-06-session-read-transient-json-500.md +--- + +# Live usage refresh floods the bridge command channel + +## Symptom + +- **Observed**: the user reported, verbatim, `I fell we are pooling` followed by a continuous block of `[Bridge] Received command: get_session_usage (requestId: ...)` messages. An independent E2E bridge artifact from Actions run `31134540427` contains: + + ```text + 217 + ``` + + occurrences of `Received command: get_session_usage` during a single approximately three-and-a-half-minute run. +- **Expected**: refreshing usage for many live sessions should use bounded command traffic and should not poll when no collectable usage exists or the dashboard is in the background. +- **Delta**: each refresh pass emits one RPC for every running session, and changes to the running-session set trigger an additional immediate all-session pass. + +## Reproduction + +1. Open the dashboard with multiple sessions whose status is `running`. +2. Add running sessions one at a time or wait for the 15-second usage interval. +3. Observe bridge logs for `get_session_usage`. + +Verified 2026-08-07: the saved bridge log contains 217 matching commands: + +```text +$ rg -c 'Received command: get_session_usage' bridge.log +217 +``` + +The implementation deterministically explains the burst: `ui/src/hooks/useSessions.ts:230-257` filters every running session and maps each one to `sendCommand`; lines 260-269 rerun that whole operation whenever the running ID set changes; lines 271-275 repeat it every 15 seconds. + +## Hypotheses + +#### H1: The UI deliberately fans each refresh pass out to every running session and retriggers the full pass when the running set changes + +- **Layer**: code-logic +- **Prediction**: one refresh with N running sessions emits N distinct `get_session_usage` RPCs; adding sessions sequentially also causes immediate repeated passes over the previously running sessions. +- **Verification method**: inspect the live polling effect and count commands in an independent bridge run. +- **Evidence**: + + ```ts + const running = sessionsRef.current.filter((session) => session.status === "running"); + await Promise.all(running.map(async (session) => { + // ... + const response = await sendCommand({ + type: "get_session_usage", + ``` + + ```ts + useEffect(() => { + if (!userId || !runningSessionKey) return; + void pollLiveUsage(); + }, [userId, runningSessionKey, pollLiveUsage]); + + const interval = window.setInterval(() => void pollLiveUsage(), LIVE_USAGE_POLL_MS); + ``` + + ```text + $ rg -c 'Received command: get_session_usage' bridge.log + 217 + ``` + +- **Verdict**: PROVEN +- **Rationale**: the caller implements an explicit one-command-per-running-session fan-out on both set changes and timer ticks; the runtime artifact exhibits the predicted repeated commands with unique request IDs. + +#### H2: React reconnects or rerenders leak duplicate interval timers + +- **Layer**: state-data +- **Prediction**: if H2 is the cause, the interval effect must omit cleanup or retain old timers after its dependencies change. +- **Verification method**: inspect the interval effect lifecycle at `ui/src/hooks/useSessions.ts:271-275`. +- **Evidence**: + + ```ts + const interval = window.setInterval(() => void pollLiveUsage(), LIVE_USAGE_POLL_MS); + return () => window.clearInterval(interval); + ``` + +- **Verdict**: REJECTED +- **Rationale**: the effect explicitly clears its timer. Duplicate leaked timers are unnecessary to produce the observed burst because one valid timer already fans out N commands and the running-key effect independently adds more passes. + +#### H3: The bridge retries or recursively generates usage commands + +- **Layer**: dependency-integration +- **Prediction**: if H3 is true, handling one command should schedule another command, retry, or publish a live session update that loops back into command generation. +- **Verification method**: inspect `bridge/src/command-rpc.ts:223-235` and `bridge/src/session-controller.ts:131-153`. +- **Evidence**: + + ```ts + const result = await sessionController.usage(payload.sessionId); + response = result.ok + ? { requestId: command.requestId, success: true, data: { usage: result.usage } } + : { requestId: command.requestId, success: false, error: result.message }; + break; + ``` + + For live sessions, `SessionController.usage` returns the collected value without saving or publishing a session update. +- **Verdict**: REJECTED +- **Rationale**: the bridge performs exactly one collection and response per received command and has no retry or recursive publication path for live usage. + +## 5 Whys + +Symptom: the bridge logs large bursts of `get_session_usage`. + +1. Why? Because each refresh emits one RPC per running session. +2. Why? Because the UI maps a per-session RPC across the complete running-session list. +3. Why? Because both the initial/set-change refresh and periodic refresh reuse the same unbounded fan-out. +4. Why? Because the bridge contract exposes only a single-session usage operation. +5. Why? Because the live-usage design lacked an aggregate refresh primitive and an explicit scheduling policy for capability filtering, burst coalescing, and background tabs. + +## Falsification + +- **Check performed**: adjacent-cause search. A leaked timer and a bridge retry loop could also create repeated logs. +- **Result**: the timer has explicit cleanup, and the bridge handler terminates after one response. Conversely, even with exactly one mounted timer and no bridge retry, the `running.map(sendCommand)` path necessarily emits N requests; the independent running-key effect repeats that fan-out as sessions are added. +- **Conclusion**: H1 survives. The burst is inherent in the documented scheduling and RPC shape, not dependent on a lifecycle leak or server retry. + +## Root Cause + +- **Immediate cause**: `pollLiveUsage` emits one command per running session, and is invoked by both a running-set effect and a 15-second interval (`ui/src/hooks/useSessions.ts:228-275`). +- **Architectural root**: the live-usage contract has no bridge-level batch operation or bounded client scheduling policy. +- **Rejected H2**: interval cleanup is present; one legitimate interval plus the running-set effect already reproduces the fan-out. +- **Rejected H3**: the bridge command/controller path responds once and does not recursively schedule usage requests. +- **Falsification**: removing duplicate-timer and bridge-retry explanations does not remove the deterministic N-command client fan-out. + +## Fix + +- Add a bridge-level batch usage RPC that accepts session IDs for one bridge and returns an ID-to-usage map while retaining the single-session RPC for compatibility. +- Build a pure UI polling plan that excludes sessions without collectable usage and groups eligible sessions by bridge. +- Debounce running-set changes into one batch per bridge, keep one periodic batch per bridge, and suppress polling while the document is hidden. +- Add regression tests at the existing command RPC boundary and the pure UI polling-plan seam. + +## Resolution + +- **Diff summary**: the dashboard now constructs stable, capability-filtered batches and sends `get_sessions_usage` once per bridge instead of `get_session_usage` once per running session. Running-set changes are coalesced for one second, hidden documents issue no usage traffic, and returning to a visible tab triggers one refresh. The legacy single-session command remains available. +- **Bridge contract**: `SessionController.usages` deduplicates IDs and collects them concurrently; the RPC boundary accepts at most 200 validated IDs per request and returns an ID-keyed usage object. +- **Regression tests**: + - `ui/src/lib/live-usage-polling.test.ts` proves multiple eligible sessions collapse into one bridge batch, non-collectable sessions are excluded, and large sets are safely chunked. + - `bridge/src/session-controller.test.ts` proves batch collection deduplicates IDs and tolerates sessions that disappear during refresh. + - `bridge/src/command-rpc.test.ts` proves one batch command produces one ID-keyed response. +- **Verification**: bridge 534/534 tests passed; UI 132/132 tests passed; bridge and UI TypeScript checks passed; package dry-run produced `ftown-bridge-0.19.7.tgz`. +- **Result**: for N eligible sessions on one bridge, a normal refresh now emits one command instead of N. Sessions with no structured usage collector and background tabs emit zero. diff --git a/ui/src/hooks/useSessions.ts b/ui/src/hooks/useSessions.ts index 6c17a49..b565765 100644 --- a/ui/src/hooks/useSessions.ts +++ b/ui/src/hooks/useSessions.ts @@ -16,6 +16,7 @@ import { } from "@/types"; import type { BridgeRpc } from "@/hooks/useBridgeRpc"; import { buildCodexCommand, buildCursorAgentCommand, buildGrokCommand, buildKimiCodeCommand } from "@/lib/agent-commands"; +import { buildUsagePollBatches } from "@/lib/live-usage-polling"; // Re-exported for existing consumers (NewSessionModal, session pickers); the // type now lives with the transport that produces it. @@ -27,6 +28,7 @@ export type { BridgeExecResponse } from "@/hooks/useBridgeRpc"; // authoritative 'removed' broadcast arrives. const REMOVED_TOMBSTONE_MS = 12_000; const LIVE_USAGE_POLL_MS = 15_000; +const LIVE_USAGE_COALESCE_MS = 1_000; function isSessionUsage(value: unknown): value is SessionUsage { if (typeof value !== "object" || value === null) return false; @@ -226,47 +228,47 @@ export function useSessions( }, [client, userId]); const pollLiveUsage = useCallback(async () => { - if (!userId) return; - const running = sessionsRef.current.filter((session) => session.status === "running"); - await Promise.all(running.map(async (session) => { - if (usageRequestsRef.current.has(session.id)) return; - usageRequestsRef.current.add(session.id); + if (!userId || (typeof document !== "undefined" && document.visibilityState === "hidden")) return; + const batches = buildUsagePollBatches(sessionsRef.current); + await Promise.all(batches.map(async (batch) => { + const requestKey = `${batch.bridgeId}:${batch.sessionIds.join("|")}`; + if (usageRequestsRef.current.has(requestKey)) return; + usageRequestsRef.current.add(requestKey); const requestGeneration = usageGenerationRef.current; try { const response = await sendCommand({ - type: "get_session_usage", - payload: { sessionId: session.id, bridgeId: session.bridgeId }, + type: "get_sessions_usage", + payload: { sessionIds: batch.sessionIds, bridgeId: batch.bridgeId }, requestId: uuidv4(), }); if (!response.success) return; - const usage = (response.data as { usage?: unknown } | undefined)?.usage; - if (!isSessionUsage(usage) || requestGeneration !== usageGenerationRef.current) return; - setSessions((prev) => prev.map((current) => - current.id === session.id - && current.status === "running" - && current.bridgeId === session.bridgeId + const usages = (response.data as { usages?: unknown } | undefined)?.usages; + if (typeof usages !== "object" || usages === null || requestGeneration !== usageGenerationRef.current) return; + const usageBySession = usages as Record; + setSessions((prev) => prev.map((current) => { + const usage = usageBySession[current.id]; + return current.status === "running" + && current.bridgeId === batch.bridgeId + && isSessionUsage(usage) ? { ...current, usage } - : current - )); + : current; + })); } catch { // Live usage is best-effort; the next interval retries without // disrupting terminal input or session status updates. } finally { - usageRequestsRef.current.delete(session.id); + usageRequestsRef.current.delete(requestKey); } })); }, [userId, sendCommand]); - const runningSessionKey = sessions - .filter((session) => session.status === "running") - .map((session) => session.id) - .sort() - .join("|"); + const usagePollKey = JSON.stringify(buildUsagePollBatches(sessions)); useEffect(() => { - if (!userId || !runningSessionKey) return; - void pollLiveUsage(); - }, [userId, runningSessionKey, pollLiveUsage]); + if (!userId || usagePollKey === "[]") return; + const timeout = window.setTimeout(() => void pollLiveUsage(), LIVE_USAGE_COALESCE_MS); + return () => window.clearTimeout(timeout); + }, [userId, usagePollKey, pollLiveUsage]); useEffect(() => { if (!userId) return; @@ -274,6 +276,15 @@ export function useSessions( return () => window.clearInterval(interval); }, [userId, pollLiveUsage]); + useEffect(() => { + if (!userId) return; + const refreshWhenVisible = () => { + if (document.visibilityState === "visible") void pollLiveUsage(); + }; + document.addEventListener("visibilitychange", refreshWhenVisible); + return () => document.removeEventListener("visibilitychange", refreshWhenVisible); + }, [userId, pollLiveUsage]); + const createSession = useCallback( (prompt: string, options?: CreateSessionOptions): Promise => { if (!userId) { diff --git a/ui/src/lib/live-usage-polling.test.ts b/ui/src/lib/live-usage-polling.test.ts new file mode 100644 index 0000000..4c2f565 --- /dev/null +++ b/ui/src/lib/live-usage-polling.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import type { Session } from "@/types"; +import { buildUsagePollBatches } from "./live-usage-polling"; + +function session(overrides: Partial): Session { + return { + id: "session-1", + name: "session", + command: "claude", + prompt: "", + status: "running", + bridgeId: "bridge-a", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildUsagePollBatches", () => { + it("groups only running sessions with collectable usage into one request per bridge", () => { + const batches = buildUsagePollBatches([ + session({ id: "claude-b", claudeSessionId: "native-b", workingDir: "/repo", bridgeId: "bridge-a" }), + session({ id: "shell", shellType: "shell" }), + session({ id: "completed", status: "completed", claudeSessionId: "native-done", workingDir: "/repo" }), + session({ id: "claude-pending-id", shellType: "claude", workingDir: "/repo" }), + session({ id: "codex", shellType: "codex", codexSessionId: "native-codex", bridgeId: "bridge-b" }), + session({ id: "kimi", shellType: "kimi-code", workingDir: "/repo", bridgeId: "bridge-a" }), + session({ id: "cursor", shellType: "cursor", cursorSessionId: "native-cursor" }), + session({ id: "claude-a", claudeSessionId: "native-a", workingDir: "/repo", bridgeId: "bridge-a" }), + ]); + + expect(batches).toEqual([ + { bridgeId: "bridge-a", sessionIds: ["claude-a", "claude-b", "kimi"] }, + { bridgeId: "bridge-b", sessionIds: ["codex"] }, + ]); + }); + + it("chunks a large bridge group to the RPC batch limit", () => { + const sessions = Array.from({ length: 201 }, (_, index) => session({ + id: `codex-${String(index).padStart(3, "0")}`, + shellType: "codex", + codexSessionId: `native-${index}`, + })); + + const batches = buildUsagePollBatches(sessions); + + expect(batches).toHaveLength(2); + expect(batches[0].sessionIds).toHaveLength(200); + expect(batches[1].sessionIds).toEqual(["codex-200"]); + }); +}); diff --git a/ui/src/lib/live-usage-polling.ts b/ui/src/lib/live-usage-polling.ts new file mode 100644 index 0000000..6ba0928 --- /dev/null +++ b/ui/src/lib/live-usage-polling.ts @@ -0,0 +1,36 @@ +import type { Session } from "@/types"; + +export interface UsagePollBatch { + bridgeId: string; + sessionIds: string[]; +} + +const MAX_USAGE_BATCH_SIZE = 200; + +function hasCollectableUsage(session: Session): boolean { + if (session.status !== "running") return false; + if (session.codexSessionId) return true; + if (session.claudeSessionId && session.workingDir) return true; + return session.shellType === "kimi-code" && Boolean(session.workingDir); +} + +/** Build one stable usage request per bridge, excluding sessions no collector can read. */ +export function buildUsagePollBatches(sessions: Session[]): UsagePollBatch[] { + const grouped = new Map(); + for (const session of sessions) { + if (!hasCollectableUsage(session)) continue; + const ids = grouped.get(session.bridgeId) ?? []; + ids.push(session.id); + grouped.set(session.bridgeId, ids); + } + + const batches: UsagePollBatch[] = []; + const groups = Array.from(grouped).sort(([a], [b]) => a.localeCompare(b)); + for (const [bridgeId, unsortedIds] of groups) { + const sessionIds = unsortedIds.sort(); + for (let offset = 0; offset < sessionIds.length; offset += MAX_USAGE_BATCH_SIZE) { + batches.push({ bridgeId, sessionIds: sessionIds.slice(offset, offset + MAX_USAGE_BATCH_SIZE) }); + } + } + return batches; +} diff --git a/ui/src/types.ts b/ui/src/types.ts index 4cae8c6..00062b9 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -185,7 +185,7 @@ export type CommandType = | 'update_session_parent' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs' - | 'get_session_usage'; + | 'get_session_usage' | 'get_sessions_usage'; export interface Command { type: CommandType; @@ -236,6 +236,16 @@ export interface RemoveSessionPayload { onlyIfFinished?: boolean; } +export interface GetSessionUsagePayload { + sessionId: string; + bridgeId?: string; +} + +export interface GetSessionsUsagePayload { + sessionIds: string[]; + bridgeId?: string; +} + export interface BridgeExecPayload { command: string; workingDir?: string; @@ -250,7 +260,7 @@ export interface DeleteLoopPayload { bridgeId: string; loopId: string } export interface RunLoopNowPayload { bridgeId: string; loopId: string } export interface GetLoopRunsPayload { bridgeId?: string; loopId: string } -export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | UpdateSessionParentPayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record; +export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | UpdateSessionParentPayload | GetSessionUsagePayload | GetSessionsUsagePayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record; export interface CommandResponse { requestId: string;