From 9dcaf83a4b5f7c5674fc50149b931fc4a30e6048 Mon Sep 17 00:00:00 2001 From: shaikh-amer Date: Thu, 30 Apr 2026 01:33:16 +0530 Subject: [PATCH] =?UTF-8?q?feat(session):=20add=20Session=20Audit=20Timeli?= =?UTF-8?q?ne=20=E2=80=94=20implement=20the=20Auditable=20pillar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/surface/session-audit-export.ts | 50 +++ .../surface/session-audit-timeline.tsx | 177 ++++++++ .../session/surface/session-surface.tsx | 382 ++++++----------- .../session/sync/session-audit-store.ts | 399 ++++++++++++++++++ 4 files changed, 766 insertions(+), 242 deletions(-) create mode 100644 apps/app/src/react-app/domains/session/surface/session-audit-export.ts create mode 100644 apps/app/src/react-app/domains/session/surface/session-audit-timeline.tsx create mode 100644 apps/app/src/react-app/domains/session/sync/session-audit-store.ts diff --git a/apps/app/src/react-app/domains/session/surface/session-audit-export.ts b/apps/app/src/react-app/domains/session/surface/session-audit-export.ts new file mode 100644 index 000000000..5971f3b83 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/session-audit-export.ts @@ -0,0 +1,50 @@ +import type { AuditEntry } from "../sync/session-audit-store"; + +type SessionAuditExportPayload = { + sessionId: string; + exportedAt: number; + entries: AuditEntry[]; +}; + +function toSessionAuditExportPayload(entries: AuditEntry[], sessionId: string): SessionAuditExportPayload { + return { + sessionId, + exportedAt: Date.now(), + entries, + }; +} + +export function formatSessionAuditAsJson(entries: AuditEntry[], sessionId: string): string { + const payload = toSessionAuditExportPayload(entries, sessionId); + return JSON.stringify(payload, null, 2); +} + +function buildAuditFilename(sessionId: string, exportedAtMs: number): string { + const normalizedSessionId = sessionId.trim() || "session"; + const safeSessionId = normalizedSessionId.replace(/[^a-zA-Z0-9_-]/g, "_"); + const isoStamp = new Date(exportedAtMs).toISOString().replace(/[:.]/g, "-"); + return `openwork-session-audit-${safeSessionId}-${isoStamp}.json`; +} + +export function downloadSessionAuditJson(entries: AuditEntry[], sessionId: string): void { + if (typeof window === "undefined") return; + + const payload = toSessionAuditExportPayload(entries, sessionId); + const content = JSON.stringify(payload, null, 2); + const filename = buildAuditFilename(payload.sessionId, payload.exportedAt); + + const blob = new Blob([content], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/apps/app/src/react-app/domains/session/surface/session-audit-timeline.tsx b/apps/app/src/react-app/domains/session/surface/session-audit-timeline.tsx new file mode 100644 index 000000000..fd92bbace --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/session-audit-timeline.tsx @@ -0,0 +1,177 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import type { UIMessage } from "ai"; + +import { Button } from "../../../design-system/button"; +import { createSessionAuditStore, type AuditEntry } from "../sync/session-audit-store"; +import { downloadSessionAuditJson } from "./session-audit-export"; + +type SessionAuditTimelineProps = { + opencodeBaseUrl: string; + openworkToken: string; + sessionId: string; + transcriptMessages?: UIMessage[]; + onClose: () => void; +}; + +function entryIcon(entry: AuditEntry): string { + if (entry.source === "tool") return "🔧"; + if (entry.source === "pty") return "🖥️"; + return "⚠️"; +} + +function statusBadgeClass(status: AuditEntry["status"]): string { + if (status === "running") return "border-blue-7/35 bg-blue-3/25 text-blue-11"; + if (status === "completed") return "border-green-7/35 bg-green-3/25 text-green-11"; + if (status === "error") return "border-red-7/35 bg-red-3/25 text-red-11"; + return "border-gray-6 bg-gray-3/50 text-gray-10"; +} + +function statusLabel(status: AuditEntry["status"]): string { + if (status === "running") return "running"; + if (status === "completed") return "completed"; + if (status === "error") return "error"; + return "pending"; +} + +function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const hh = `${date.getHours()}`.padStart(2, "0"); + const mm = `${date.getMinutes()}`.padStart(2, "0"); + const ss = `${date.getSeconds()}`.padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +function deriveEntriesFromTranscript(sessionId: string, messages: UIMessage[] | undefined): AuditEntry[] { + if (!messages?.length) return []; + const entries: AuditEntry[] = []; + let index = 0; + for (const message of messages) { + if (message.role !== "assistant") continue; + for (const part of message.parts) { + if (part.type !== "dynamic-tool") continue; + index += 1; + const state = part.state; + let status: AuditEntry["status"] = "running"; + let outputSummary = ""; + if (state === "output-available") { + status = "completed"; + outputSummary = typeof part.output === "string" ? part.output : JSON.stringify(part.output); + } else if (state === "output-error") { + status = "error"; + outputSummary = part.errorText; + } + const inputSummary = typeof part.input === "string" ? part.input : JSON.stringify(part.input ?? {}); + entries.push({ + id: `fallback:${sessionId}:${message.id}:${part.toolCallId ?? index}`, + source: "tool", + sessionId, + timestamp: Date.now() + index, + title: part.toolName || "Tool", + status, + inputSummary, + outputSummary, + toolName: part.toolName || "Tool", + callId: part.toolCallId || undefined, + }); + } + } + return entries; +} + +export function SessionAuditTimeline(props: SessionAuditTimelineProps) { + const store = useMemo( + () => + createSessionAuditStore({ + opencodeBaseUrl: props.opencodeBaseUrl, + openworkToken: props.openworkToken, + sessionId: props.sessionId, + }), + [props.opencodeBaseUrl, props.openworkToken, props.sessionId], + ); + + useEffect(() => () => store.dispose(), [store]); + + const subscribe = useCallback( + (listener: () => void) => store.subscribe(listener), + [store], + ); + const getSnapshot = useCallback( + () => store.getSnapshot(), + [store], + ); + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const fallbackEntries = useMemo( + () => deriveEntriesFromTranscript(props.sessionId, props.transcriptMessages), + [props.sessionId, props.transcriptMessages], + ); + const visibleEntries = snapshot.entries.length > 0 ? snapshot.entries : fallbackEntries; + + return ( + + ); +} diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 9ee105449..9c51e10ce 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -1,12 +1,10 @@ /** @jsxImportSource react */ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { UIMessage } from "ai"; import { useQuery } from "@tanstack/react-query"; -import type { SessionStatus } from "@opencode-ai/sdk/v2/client"; import { createClient, unwrap } from "../../../../app/lib/opencode"; import { abortSessionSafe } from "../../../../app/lib/opencode-session"; -import { readWorkspaceCloudImports, type CloudImportedPlugin } from "../../../../app/cloud/import-state"; import type { OpenworkServerClient, OpenworkSessionSnapshot, @@ -28,29 +26,20 @@ import { ReactSessionComposer } from "./composer/composer"; import { DevProfiler } from "../../../shell/dev-profiler"; import { OwDotTicker } from "../../../shell/dot-ticker"; import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; +import { Button } from "../../../design-system/button"; import type { ReactComposerNotice } from "./composer/notice"; import { SessionDebugPanel } from "./debug-panel"; -import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot } from "./session-render-state"; +import { SessionAuditTimeline } from "./session-audit-timeline"; import { SessionTranscript } from "./message-list"; import { deriveSessionRenderModel } from "../sync/transition-controller"; import { useSessionScrollController } from "./scroll-controller"; import { seedSessionState, statusKey as reactStatusKey, + todoKey as reactTodoKey, transcriptKey as reactTranscriptKey, } from "../sync/session-sync"; - -const EMPTY_TRANSCRIPT: UIMessage[] = []; -const IDLE_STATUS: SessionStatus = { type: "idle" }; - -type SessionError = { - message: string; - kind?: "model-not-found" | "generic"; - /** For model-not-found: the model that failed. */ - failedModel?: { providerID: string; modelID: string }; - /** For model-not-found: suggested replacements from the backend. */ - suggestions?: Array<{ providerID: string; modelID: string }>; -}; +import { snapshotToUIMessages } from "../sync/usechat-adapter"; export type SessionSurfaceProps = { client: OpenworkServerClient; @@ -79,7 +68,6 @@ export type SessionSurfaceProps = { searchFiles: (query: string) => Promise; isRemoteWorkspace: boolean; isSandboxWorkspace: boolean; - onChangeModel?: (model: { providerID: string; modelID: string }) => void; onUploadInboxFiles?: ((files: File[], options?: { notify?: boolean }) => void | Promise) | null; onOpenSettingsSection?: ((section: "commands" | "skills" | "mcps" | "plugins") => void) | undefined; }; @@ -115,9 +103,6 @@ function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolea function useSharedQueryState(queryKey: readonly unknown[], fallback: T) { const queryClient = getReactQueryClient(); - // useSyncExternalStore requires getSnapshot to return the same reference - // while the external store has not changed. Callers must pass stable - // fallbacks for empty cache states. return useSyncExternalStore( (callback) => queryClient.getQueryCache().subscribe(callback), () => (queryClient.getQueryData(queryKey) ?? fallback), @@ -136,7 +121,7 @@ function messageHasVisibleAssistantOutput(message: UIMessage) { function AssistantWaitingCard() { return (
-
+
Thinking
@@ -144,87 +129,6 @@ function AssistantWaitingCard() { ); } -function parseSessionError(thrown: unknown): SessionError { - const raw = thrown instanceof Error ? thrown.message : String(thrown); - // Try to detect ProviderModelNotFoundError from the SDK error shape. - // The error message may be a JSON string from our serializer in session-route. - try { - const parsed = JSON.parse(raw); - if (parsed?.name === "ProviderModelNotFoundError" && parsed?.data) { - const { providerID, modelID, suggestions } = parsed.data; - return { - message: `Model ${providerID}/${modelID} is not available.`, - kind: "model-not-found", - failedModel: { providerID, modelID }, - suggestions: Array.isArray(suggestions) ? suggestions : [], - }; - } - } catch { - // Not JSON — fall through to plain message - } - // Check if the raw string mentions model-not-found patterns - if (/ProviderModelNotFoundError/i.test(raw) || /model.*not found/i.test(raw)) { - return { message: raw, kind: "model-not-found" }; - } - return { message: raw || "Failed to send prompt." }; -} - -function SessionErrorCard({ error, onDismiss, onChangeModel, onOpenModelPicker }: { - error: SessionError; - onDismiss: () => void; - onChangeModel?: (model: { providerID: string; modelID: string }) => void; - onOpenModelPicker?: () => void; -}) { - return ( -
-
-
-
-
{error.message}
- {error.kind === "model-not-found" ? ( -
- {error.suggestions && error.suggestions.length > 0 ? ( - error.suggestions.map((s) => ( - - )) - ) : null} - -
- ) : null} -
- -
-
-
- ); -} - function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined }) { if (!attachment.previewUrl) return; URL.revokeObjectURL(attachment.previewUrl); @@ -236,7 +140,7 @@ export function SessionSurface(props: SessionSurfaceProps) { const [mentions, setMentions] = useState>({}); const [pasteParts, setPasteParts] = useState>([]); const [notice, setNotice] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [sending, setSending] = useState(false); const [showDelayedLoading, setShowDelayedLoading] = useState(false); const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState(null); @@ -245,7 +149,7 @@ export function SessionSurface(props: SessionSurfaceProps) { const [toolMcpServers, setToolMcpServers] = useState([]); const [toolMcpStatus, setToolMcpStatus] = useState(null); const [toolMcpStatuses, setToolMcpStatuses] = useState({}); - const [toolImportedPlugins, setToolImportedPlugins] = useState([]); + const [showAudit, setShowAudit] = useState(false); const hydratedKeyRef = useRef(null); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; @@ -266,6 +170,21 @@ export function SessionSurface(props: SessionSurfaceProps) { () => reactStatusKey(props.workspaceId, props.sessionId), [props.workspaceId, props.sessionId], ); + const todoQueryKey = useMemo( + () => reactTodoKey(props.workspaceId, props.sessionId), + [props.workspaceId, props.sessionId], + ); + + useEffect(() => { + return () => { + const queryClient = getReactQueryClient(); + queryClient.removeQueries({ queryKey: snapshotQueryKey, exact: true }); + queryClient.removeQueries({ queryKey: transcriptQueryKey, exact: true }); + queryClient.removeQueries({ queryKey: statusQueryKey, exact: true }); + queryClient.removeQueries({ queryKey: todoQueryKey, exact: true }); + }; + }, [snapshotQueryKey, transcriptQueryKey, statusQueryKey, todoQueryKey]); + const snapshotQuery = useQuery({ queryKey: snapshotQueryKey, queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item, @@ -273,8 +192,9 @@ export function SessionSurface(props: SessionSurfaceProps) { }); const currentSnapshot = snapshotQuery.data?.session.id === props.sessionId ? snapshotQuery.data : null; - const transcriptState = useSharedQueryState(transcriptQueryKey, EMPTY_TRANSCRIPT); - const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? IDLE_STATUS); + const transcriptState = useSharedQueryState(transcriptQueryKey, []); + const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? { type: "idle" as const }); + useSharedQueryState(todoQueryKey, currentSnapshot?.todos ?? []); useEffect(() => { if (!currentSnapshot) return; @@ -371,17 +291,10 @@ export function SessionSurface(props: SessionSurfaceProps) { seedSessionState(props.workspaceId, currentSnapshot); }, [props.sessionId, currentSnapshot, props.workspaceId]); - const snapshot = resolveRenderedSessionSnapshot({ - sessionId: props.sessionId, - currentSnapshot, - cachedRendered: rendered, - }); - const liveStatus = statusState ?? snapshot?.status ?? IDLE_STATUS; + const snapshot = currentSnapshot ?? rendered?.snapshot ?? null; + const liveStatus = statusState ?? snapshot?.status ?? { type: "idle" as const }; const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry"; - const renderedMessages = useMemo( - () => deriveRenderedSessionMessages({ transcriptState, snapshot }), - [snapshot, transcriptState], - ); + const renderedMessages = transcriptState ?? []; const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0; const assistantOutputAfterAwaitStart = useMemo(() => { if (awaitingAssistantBaseline === null) return false; @@ -423,13 +336,13 @@ export function SessionSurface(props: SessionSurfaceProps) { const model = deriveSessionRenderModel({ intendedSessionId: props.sessionId, - renderedSessionId: renderedMessages.length > 0 || snapshot ? props.sessionId : null, + renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null, hasSnapshot: Boolean(snapshot) || renderedMessages.length > 0, isFetching: snapshotQuery.isFetching, isError: snapshotQuery.isError || Boolean(error), }); - const buildDraft = useCallback((text: string, nextAttachments: ComposerAttachment[]): ComposerDraft => { + const buildDraft = (text: string, nextAttachments: ComposerAttachment[]): ComposerDraft => { const trimmed = text.trim(); const slashMatch = trimmed.match(/^\/([^\s]+)\s*(.*)$/); const parts: ComposerPart[] = text.split(/(\[pasted text [^\]]+\]|@[^\s@]+)/).flatMap((segment) => { @@ -449,27 +362,21 @@ export function SessionSurface(props: SessionSurfaceProps) { } return [{ type: "text", text: segment } satisfies ComposerDraft["parts"][number]]; }); - // Expand paste placeholders in resolvedText so the model receives - // the actual pasted content instead of "[pasted text
) : null} -
-
{ - sessionScroll.markScrollGesture(event.target); - }} - onTouchStart={(event) => { - sessionScroll.markScrollGesture(event.target); - }} - onTouchMove={(event) => { - sessionScroll.markScrollGesture(event.target); - }} - onPointerDown={(event) => { - if (event.target !== event.currentTarget) return; - sessionScroll.markScrollGesture(event.currentTarget); - }} - onScroll={sessionScroll.handleScroll} - className="absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-4 sm:px-5" +
+ +
+ +
+
+
{ + sessionScroll.markScrollGesture(event.target); + }} + onTouchStart={(event) => { + sessionScroll.markScrollGesture(event.target); + }} + onTouchMove={(event) => { + sessionScroll.markScrollGesture(event.target); + }} + onPointerDown={(event) => { + if (event.target !== event.currentTarget) return; + sessionScroll.markScrollGesture(event.currentTarget); + }} + onScroll={sessionScroll.handleScroll} + className="absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-4 sm:px-5" + > + {/* Chat column: tighter than the composer (800px) so messages + keep a comfortable reading width and don't feel "too big". */} +
+ {showDelayedLoading && pendingSessionLoad ? ( +
+
+
Loading React session view...
+
-
- ) : (snapshotQuery.isError || error) && !snapshot && renderedMessages.length === 0 ? ( -
- {error ? ( - setError(null)} - onChangeModel={props.onChangeModel} - onOpenModelPicker={props.onModelClick} - /> - ) : ( + ) : (snapshotQuery.isError || error) && !snapshot && renderedMessages.length === 0 ? ( +
- {snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load session."} + {error || (snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load React session view.")}
- )} -
- ) : renderedMessages.length === 0 && showAssistantWaitState ? ( -
- -
- ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? ( - error ? ( - setError(null)} - onChangeModel={props.onChangeModel} - onOpenModelPicker={props.onModelClick} - /> - ) : null - ) : ( - - <> - scrollRef.current} - /> - {error ? ( - setError(null)} - onChangeModel={props.onChangeModel} - onOpenModelPicker={props.onModelClick} +
+ ) : renderedMessages.length === 0 && showAssistantWaitState ? ( +
+ +
+ ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? ( + null + ) : ( + + <> + scrollRef.current} /> - ) : null} - {showAssistantWaitState ? : null} - - - )} -
-
- {!sessionScroll.isAtBottom || sessionScroll.topClippedMessageId ? ( -
-
- {sessionScroll.topClippedMessageId ? ( - - ) : null} - {!sessionScroll.isAtBottom ? ( - - ) : null} + {showAssistantWaitState ? : null} + + + )}
+ {!sessionScroll.isAtBottom || sessionScroll.topClippedMessageId ? ( +
+
+ {sessionScroll.topClippedMessageId ? ( + + ) : null} + {!sessionScroll.isAtBottom ? ( + + ) : null} +
+
+ ) : null} +
+ {showAudit ? ( + setShowAudit(false)} + /> ) : null}
@@ -827,8 +723,6 @@ export function SessionSurface(props: SessionSurfaceProps) { mcpServers={toolMcpServers} mcpStatus={toolMcpStatus} mcpStatuses={toolMcpStatuses} - listImportedPlugins={listImportedPlugins} - importedPlugins={toolImportedPlugins} onOpenSettingsSection={props.onOpenSettingsSection} recentFiles={props.recentFiles} searchFiles={props.searchFiles} @@ -846,7 +740,11 @@ export function SessionSurface(props: SessionSurfaceProps) { />
- {/* Error display moved inline into the session conversation area */} + {error ? ( +
+
{error}
+
+ ) : null} {props.developerMode ? : null}
diff --git a/apps/app/src/react-app/domains/session/sync/session-audit-store.ts b/apps/app/src/react-app/domains/session/sync/session-audit-store.ts new file mode 100644 index 000000000..ff265a32f --- /dev/null +++ b/apps/app/src/react-app/domains/session/sync/session-audit-store.ts @@ -0,0 +1,399 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2/client"; + +import { createClient } from "../../../../app/lib/opencode"; +import { normalizeEvent, safeStringify } from "../../../../app/utils"; + +const MAX_AUDIT_ENTRIES = 500; +const SUMMARY_MAX_CHARS = 220; + +type Listener = () => void; + +type AuditEntrySource = "tool" | "pty" | "session-error"; +type AuditEntryStatus = "pending" | "running" | "completed" | "error"; + +export type AuditEntry = { + id: string; + source: AuditEntrySource; + sessionId: string; + timestamp: number; + title: string; + status: AuditEntryStatus; + inputSummary: string; + outputSummary: string; + toolName?: string; + ptyId?: string; + callId?: string; +}; + +export type SessionAuditSnapshot = { + entries: AuditEntry[]; + connected: boolean; + error: string | null; +}; + +export type SessionAuditStore = { + subscribe: (listener: Listener) => () => void; + getSnapshot: () => SessionAuditSnapshot; + dispose: () => void; +}; + +type CreateSessionAuditStoreInput = { + opencodeBaseUrl: string; + openworkToken: string; + sessionId: string; +}; + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as UnknownRecord; +} + +function readString(record: UnknownRecord, key: string): string { + const value = record[key]; + return typeof value === "string" ? value : ""; +} + +function readNumber(record: UnknownRecord, key: string): number | null { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function summarizeValue(value: unknown, maxChars = SUMMARY_MAX_CHARS): string { + if (value === undefined || value === null) return ""; + const text = typeof value === "string" ? value : safeStringify(value); + const compact = text.replace(/\s+/g, " ").trim(); + if (!compact) return ""; + if (compact.length <= maxChars) return compact; + return `${compact.slice(0, maxChars - 1)}...`; +} + +function mapToolStatus(status: string): AuditEntryStatus { + if (status === "completed") return "completed"; + if (status === "error") return "error"; + if (status === "running") return "running"; + return "pending"; +} + +function getErrorMessage(value: unknown): string { + const record = asRecord(value); + if (!record) return ""; + const direct = readString(record, "message"); + if (direct) return direct; + const data = asRecord(record.data); + if (!data) return ""; + return readString(data, "message"); +} + +function trimEntries(entries: AuditEntry[]): AuditEntry[] { + if (entries.length <= MAX_AUDIT_ENTRIES) return entries; + return entries.slice(entries.length - MAX_AUDIT_ENTRIES); +} + +export function createSessionAuditStore(input: CreateSessionAuditStoreInput): SessionAuditStore { + const listeners = new Set(); + const abortController = new AbortController(); + const toolEntryByPartId = new Map(); + const ptyEntryById = new Map(); + + let nextId = 0; + let disposed = false; + let started = false; + let snapshot: SessionAuditSnapshot = { + entries: [], + connected: false, + error: null, + }; + + const emit = () => { + for (const listener of listeners) listener(); + }; + + const setSnapshotAndEmit = (updater: (current: SessionAuditSnapshot) => SessionAuditSnapshot) => { + snapshot = updater(snapshot); + emit(); + }; + + const setSnapshotSilently = (updater: (current: SessionAuditSnapshot) => SessionAuditSnapshot) => { + snapshot = updater(snapshot); + }; + + const addEntry = (entry: AuditEntry) => { + setSnapshotAndEmit((current) => ({ + ...current, + entries: trimEntries([...current.entries, entry]), + })); + }; + + const updateEntry = (entryId: string, updater: (entry: AuditEntry) => AuditEntry) => { + setSnapshotAndEmit((current) => { + const index = current.entries.findIndex((item) => item.id === entryId); + if (index === -1) return current; + const entries = current.entries.slice(); + entries[index] = updater(entries[index]!); + return { ...current, entries }; + }); + }; + + const createEntryId = () => { + nextId += 1; + return `audit:${input.sessionId}:${Date.now()}:${nextId}`; + }; + + const handleToolPartUpdated = (properties: UnknownRecord) => { + const part = properties.part; + const partRecord = asRecord(part); + if (!partRecord) return; + if (readString(partRecord, "type") !== "tool") return; + + const partSessionId = readString(partRecord, "sessionID"); + const eventSessionId = readString(properties, "sessionID"); + const ownerSessionId = partSessionId || eventSessionId; + if (ownerSessionId !== input.sessionId) return; + + const toolPart = part as ToolPart; + const stateRecord = asRecord(toolPart.state); + if (!stateRecord) return; + + const partId = readString(partRecord, "id"); + if (!partId) return; + + const timestamp = readNumber(properties, "time") ?? Date.now(); + const status = mapToolStatus(readString(stateRecord, "status")); + const inputSummary = summarizeValue(stateRecord.input); + const outputSummary = + status === "completed" + ? summarizeValue(stateRecord.output) + : status === "error" + ? summarizeValue(stateRecord.error) + : ""; + + const title = toolPart.tool?.trim() ? toolPart.tool.trim() : "Tool"; + const existingId = toolEntryByPartId.get(partId) ?? null; + + if (!existingId) { + const entryId = createEntryId(); + toolEntryByPartId.set(partId, entryId); + addEntry({ + id: entryId, + source: "tool", + sessionId: input.sessionId, + timestamp, + title, + status, + inputSummary, + outputSummary, + toolName: title, + callId: readString(partRecord, "callID") || undefined, + }); + return; + } + + updateEntry(existingId, (current) => ({ + ...current, + timestamp, + status, + inputSummary: inputSummary || current.inputSummary, + outputSummary: outputSummary || current.outputSummary, + title: title || current.title, + toolName: title || current.toolName, + callId: readString(partRecord, "callID") || current.callId, + })); + }; + + const buildPtyCommand = (info: UnknownRecord) => { + const command = readString(info, "command"); + const argsRaw = info.args; + const args = Array.isArray(argsRaw) ? argsRaw.filter((item): item is string => typeof item === "string") : []; + return [command, ...args].filter(Boolean).join(" ").trim(); + }; + + const handlePtyCreatedOrUpdated = (properties: UnknownRecord) => { + const info = asRecord(properties.info); + if (!info) return; + const ptyId = readString(info, "id"); + if (!ptyId) return; + + const title = readString(info, "title") || "Shell command"; + const commandSummary = buildPtyCommand(info); + const statusText = readString(info, "status"); + const status: AuditEntryStatus = + statusText === "running" ? "running" : statusText === "exited" ? "completed" : "pending"; + const timestamp = Date.now(); + const existingId = ptyEntryById.get(ptyId) ?? null; + + if (!existingId) { + const entryId = createEntryId(); + ptyEntryById.set(ptyId, entryId); + addEntry({ + id: entryId, + source: "pty", + sessionId: input.sessionId, + timestamp, + title, + status, + inputSummary: commandSummary, + outputSummary: "", + ptyId, + }); + return; + } + + updateEntry(existingId, (current) => ({ + ...current, + timestamp, + title: title || current.title, + status, + inputSummary: commandSummary || current.inputSummary, + })); + }; + + const handlePtyExited = (properties: UnknownRecord) => { + const ptyId = readString(properties, "id"); + if (!ptyId) return; + const exitCode = readNumber(properties, "exitCode"); + const existingId = ptyEntryById.get(ptyId) ?? null; + const timestamp = Date.now(); + const outputSummary = exitCode === null ? "Exited" : `Exited with code ${exitCode}`; + const status: AuditEntryStatus = exitCode === null || exitCode === 0 ? "completed" : "error"; + + if (!existingId) { + const entryId = createEntryId(); + ptyEntryById.set(ptyId, entryId); + addEntry({ + id: entryId, + source: "pty", + sessionId: input.sessionId, + timestamp, + title: "Shell command", + status, + inputSummary: "", + outputSummary, + ptyId, + }); + return; + } + + updateEntry(existingId, (current) => ({ + ...current, + timestamp, + status, + outputSummary, + })); + }; + + const handleSessionError = (properties: UnknownRecord) => { + const sessionId = readString(properties, "sessionID"); + if (sessionId && sessionId !== input.sessionId) return; + + const message = getErrorMessage(properties.error) || "Session failed"; + addEntry({ + id: createEntryId(), + source: "session-error", + sessionId: input.sessionId, + timestamp: Date.now(), + title: "Session error", + status: "error", + inputSummary: "", + outputSummary: summarizeValue(message), + }); + }; + + const hydrateFromSessionHistory = async (client: ReturnType) => { + try { + const response = await client.session.messages({ sessionID: input.sessionId, limit: 200 }); + const responseRecord = asRecord(response); + const messagesRaw = responseRecord?.data; + if (!Array.isArray(messagesRaw)) return; + for (const message of messagesRaw) { + const messageRecord = asRecord(message); + const partsRaw = messageRecord?.parts; + if (!Array.isArray(partsRaw)) continue; + for (const part of partsRaw) { + const partRecord = asRecord(part); + if (!partRecord) continue; + if (readString(partRecord, "type") !== "tool") continue; + const timeRecord = asRecord(asRecord(partRecord.state)?.time); + const time = readNumber(timeRecord ?? {}, "start") ?? Date.now(); + handleToolPartUpdated({ + sessionID: input.sessionId, + part, + time, + }); + } + } + } catch { + // Non-fatal: realtime SSE updates still flow when available. + } + }; + + const start = async () => { + const client = createClient(input.opencodeBaseUrl, undefined, { + token: input.openworkToken, + mode: "openwork", + }); + + try { + const subscription = await client.event.subscribe(undefined, { signal: abortController.signal }); + setSnapshotSilently((current) => ({ ...current, connected: true, error: null })); + await hydrateFromSessionHistory(client); + + for await (const raw of subscription.stream) { + if (disposed || abortController.signal.aborted) return; + const event = normalizeEvent(raw); + if (!event) continue; + const properties = asRecord(event.properties); + if (!properties) continue; + + if (event.type === "message.part.updated") { + handleToolPartUpdated(properties); + continue; + } + if (event.type === "pty.created" || event.type === "pty.updated") { + handlePtyCreatedOrUpdated(properties); + continue; + } + if (event.type === "pty.exited") { + handlePtyExited(properties); + continue; + } + if (event.type === "session.error") { + handleSessionError(properties); + } + } + } catch (error) { + if (abortController.signal.aborted) return; + const message = error instanceof Error ? error.message : "Failed to subscribe to audit events."; + setSnapshotSilently((current) => ({ ...current, connected: false, error: message })); + } finally { + if (!disposed) { + setSnapshotSilently((current) => ({ ...current, connected: false })); + } + } + }; + const ensureStarted = () => { + if (started || disposed) return; + started = true; + void start(); + }; + + return { + subscribe(listener) { + listeners.add(listener); + ensureStarted(); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot() { + return snapshot; + }, + dispose() { + if (disposed) return; + disposed = true; + abortController.abort(); + listeners.clear(); + }, + }; +}