From 9108dff588067e1484c765dfda7c3c0b841c0f47 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 3 Aug 2026 19:26:04 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BB=BB=E5=8A=A1=E4=B8=8E?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=AE=9E=E6=97=B6=E6=B4=BB=E5=8A=A8=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- src/features/activity/activity.css | 20 ++++++ src/features/activity/components.test.tsx | 35 ++++++++++ src/features/activity/components.tsx | 63 ++++++++++++++++++ src/features/activity/index.ts | 4 ++ src/features/activity/memoryAdapter.test.ts | 30 +++++++++ src/features/activity/memoryAdapter.ts | 34 ++++++++++ src/features/activity/state.test.ts | 33 +++++++++ src/features/activity/state.ts | 66 ++++++++++++++++++ src/features/activity/types.ts | 74 +++++++++++++++++++++ 9 files changed, 359 insertions(+) create mode 100644 src/features/activity/activity.css create mode 100644 src/features/activity/components.test.tsx create mode 100644 src/features/activity/components.tsx create mode 100644 src/features/activity/index.ts create mode 100644 src/features/activity/memoryAdapter.test.ts create mode 100644 src/features/activity/memoryAdapter.ts create mode 100644 src/features/activity/state.test.ts create mode 100644 src/features/activity/state.ts create mode 100644 src/features/activity/types.ts diff --git a/src/features/activity/activity.css b/src/features/activity/activity.css new file mode 100644 index 0000000..3fe8ac6 --- /dev/null +++ b/src/features/activity/activity.css @@ -0,0 +1,20 @@ +.activity-status { display:inline-flex; align-items:center; gap:6px; font-size:12px; white-space:nowrap } +.activity-status__mark { width:8px; height:8px; border:2px solid currentColor; border-radius:50% } +.activity-status--running .activity-status__mark { border-radius:2px } +.activity-status--succeeded { color:#277a4b }.activity-status--failed,.activity-status--cancelled { color:#a23d3d } +.activity-list { display:grid; border:1px solid #d9dde5; border-radius:8px; overflow:hidden } +.activity-list__row { min-height:38px; display:grid; grid-template-columns:minmax(150px,1fr) 100px 90px; align-items:center; gap:12px; padding:0 12px; border:0; border-bottom:1px solid #e6e8ed; background:#fff; text-align:left; color:inherit } +.activity-list__row:hover,.activity-list__row[aria-pressed="true"] { background:#f1f4f9 } +.activity-empty { padding:24px; color:#667085; text-align:center; border:1px dashed #cfd4dc; border-radius:8px } +.activity-detail { display:grid; gap:16px }.activity-detail>header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start }.activity-detail h2 { margin:0;font-size:16px }.activity-detail dl,.activity-dialog dl { display:grid; grid-template-columns:120px 1fr; margin:0; gap:8px 12px }.activity-detail dt,.activity-dialog dt { color:#667085 }.activity-detail dd,.activity-dialog dd { margin:0; overflow-wrap:anywhere } +.activity-timeline { display:flex; padding:0; list-style:none; gap:8px }.activity-timeline li { display:grid; border-left:3px solid #8793a8; padding-left:10px; min-width:130px }.activity-timeline time { color:#667085;font-size:11px } +.activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#f5f6f8 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 } +.activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#f5f6f8; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace } +.activity-danger { color:#a12323; border-color:#cf7777 } +.activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#1118 }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:white;border-radius:10px;padding:20px;box-shadow:0 18px 60px #0004 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px } +.activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px } +.activity-events { min-height:200px;border:1px solid #d9dde5;border-radius:8px;overflow:hidden }.activity-events__feed { max-height:420px;overflow:auto }.activity-event { width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#fff;border:0;border-bottom:1px solid #e6e8ed;color:inherit }.activity-event:hover { background:#f5f7fa }.activity-event span { color:#667085 }.activity-event--new { background:#edf5ff } +.activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #e2b75e;border-radius:7px;background:#fff8e5;color:#654b14 } +button,input,select { font:inherit }button { cursor:pointer;border:1px solid #bbc2ce;border-radius:5px;padding:5px 9px;background:#fff }button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:3px solid #477ee8;outline-offset:2px }button:disabled { opacity:.5;cursor:not-allowed } +@media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } } +@media (prefers-reduced-motion:reduce) { .activity-event--new { animation:none } } diff --git a/src/features/activity/components.test.tsx b/src/features/activity/components.test.tsx new file mode 100644 index 0000000..db7f264 --- /dev/null +++ b/src/features/activity/components.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { CapabilityId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model"; +import { CancelTaskDialog, ReconnectBanner, TaskOutput } from "./components"; + +const task: Task = { + server_id: "server-a" as ServerId, project_id: "project-a" as ProjectId, session_id: "session-a" as SessionId, + task_id: "task-a" as TaskId, capability_id: "operate" as CapabilityId, state: "RUNNING", cancellable: true, + created_at: "2026-01-01T00:00:00Z" as Timestamp, updated_at: "2026-01-01T00:00:01Z" as Timestamp, +}; + +describe("activity components", () => { + it("取消确认展示固定上下文并要求显式确认", () => { + const confirm = vi.fn(); + render(); + expect(screen.getByText("server-a")).toBeInTheDocument(); + expect(screen.getByText("project-a")).toBeInTheDocument(); + expect(screen.getByText("session-a")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "确认取消" })); + expect(confirm).toHaveBeenCalledWith(task); + }); + + it("输出支持暂停和恢复自动跟随且显示脱敏状态", () => { + const changed = vi.fn(); + render(); + expect(screen.getAllByText(/已脱敏/).length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("button", { name: "暂停跟随" })); + expect(changed).toHaveBeenCalledWith(false); + }); + + it("恢复缺口使用告警语义呈现", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("检测到事件缺口"); + }); +}); diff --git a/src/features/activity/components.tsx b/src/features/activity/components.tsx new file mode 100644 index 0000000..f30501f --- /dev/null +++ b/src/features/activity/components.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import type { Event, Task } from "../../protocol/model"; +import { canCancelTask, taskDuration } from "./state"; +import type { EventFiltersValue, ResumeGap, StreamPhase, TaskOutputEntry } from "./types"; +import "./activity.css"; + +const taskLabels: Readonly> = { + PENDING: "等待中", RUNNING: "运行中", SUCCEEDED: "已成功", FAILED: "失败", CANCELLED: "已取消", +}; + +export function TaskStatus({ state }: { readonly state: Task["state"] }) { + return ; +} + +export function TaskList({ tasks, selectedTaskId, onSelect, loading = false }: { readonly tasks: readonly Task[]; readonly selectedTaskId?: string; readonly onSelect?: (task: Task) => void; readonly loading?: boolean }) { + if (loading) return
正在加载任务…
; + if (!tasks.length) return
暂无任务
; + return
{tasks.map((task) => )}
; +} + +export function TaskTimeline({ task }: { readonly task: Task }) { + return
  1. 已提交
  2. {task.state !== "PENDING" &&
  3. {taskLabels[task.state] ?? task.state}
  4. }
; +} + +export function TaskOutput({ entries, follow, onFollowChange, onCopy }: { readonly entries: readonly TaskOutputEntry[]; readonly follow: boolean; readonly onFollowChange?: (follow: boolean) => void; readonly onCopy?: (text: string) => void }) { + const outputRef = useRef(null); + useEffect(() => { if (follow && outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; }, [entries, follow]); + const text = entries.map((entry) => entry.text).join("\n"); + return
{entries.some((entry) => entry.redacted) ? "已脱敏输出" : "标准输出"}
{entries.length ? entries.map((entry) =>
{entry.text}{entry.redacted && 已脱敏}
) : 尚无输出}
; +} + +export function TaskDetail({ task, outputs = [], followOutput = true, onFollowOutputChange, onCancel }: { readonly task?: Task; readonly outputs?: readonly TaskOutputEntry[]; readonly followOutput?: boolean; readonly onFollowOutputChange?: (follow: boolean) => void; readonly onCancel?: (task: Task) => void }) { + if (!task) return
选择任务以查看详情
; + return

{task.capability_id}

{task.task_id}
持续时间
{taskDuration(task)}
固定上下文
{task.server_id} / {task.project_id} / {task.session_id}
{task.result !== undefined &&
{JSON.stringify(task.result, null, 2)}
}
; +} + +export function CancelTaskDialog({ task, open, busy = false, error, onConfirm, onClose }: { readonly task?: Task; readonly open: boolean; readonly busy?: boolean; readonly error?: string; readonly onConfirm: (task: Task) => void; readonly onClose: () => void }) { + const cancelRef = useRef(null); + useEffect(() => { if (open) cancelRef.current?.focus(); }, [open]); + if (!open || !task) return null; + return
{ if (event.target === event.currentTarget) onClose(); }}>
{ if (event.key === "Escape") onClose(); }}>

确认取消任务?

断开连接不会取消任务;只有此次明确确认才会发送取消请求。

Teamserver
{task.server_id}
Project
{task.project_id}
Session
{task.session_id}
Task
{task.task_id}
{error &&

{error}

}
; +} + +export function EventFilters({ value, knownTypes, onChange }: { readonly value: EventFiltersValue; readonly knownTypes: readonly string[]; readonly onChange: (value: EventFiltersValue) => void }) { + return
event.preventDefault()}>
; +} + +export function EventStream({ events, follow, onFollowChange, onSelect, loading = false }: { readonly events: readonly Event[]; readonly follow: boolean; readonly onFollowChange?: (follow: boolean) => void; readonly onSelect?: (event: Event) => void; readonly loading?: boolean }) { + const endRef = useRef(null); + useEffect(() => { if (follow && endRef.current?.parentElement) endRef.current.parentElement.scrollTop = endRef.current.parentElement.scrollHeight; }, [events, follow]); + return
实时事件
{loading ?
正在同步事件…
: !events.length ?
暂无匹配事件
:
{events.map((event, index) => )}
}
; +} + +export function EventDetail({ event }: { readonly event?: Event }) { + if (!event) return
选择事件以查看详情
; + return

{event.type}

事件 ID
{event.event_id}
序号 / 游标
{event.sequence ?? "—"} / {event.cursor}
上下文
{event.context.server_id} / {event.context.project_id ?? "—"} / {event.context.session_id ?? "—"}
{JSON.stringify(event.payload, null, 2)}
; +} + +export function ReconnectBanner({ phase, gap, onReconnect }: { readonly phase: StreamPhase; readonly gap?: ResumeGap; readonly onReconnect?: () => void }) { + if (["idle", "streaming", "closed"].includes(phase) && !gap) return null; + const messages: Readonly> = { loading: "正在建立事件流…", degraded: "事件流已降级,任务仍在服务端继续运行。", reconnecting: "连接中断,正在从最近游标恢复…", recovery_failed: "无法恢复事件流,请重新同步。" }; + return
{gap?.message ?? messages[phase] ?? phase}{(phase === "recovery_failed" || phase === "degraded") && }
; +} diff --git a/src/features/activity/index.ts b/src/features/activity/index.ts new file mode 100644 index 0000000..ac08999 --- /dev/null +++ b/src/features/activity/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./state"; +export * from "./memoryAdapter"; +export * from "./components"; diff --git a/src/features/activity/memoryAdapter.test.ts b/src/features/activity/memoryAdapter.test.ts new file mode 100644 index 0000000..53a2bd3 --- /dev/null +++ b/src/features/activity/memoryAdapter.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CapabilityId, Cursor, Event, EventId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model"; +import { MemoryActivityAdapter } from "./memoryAdapter"; + +const scope = { server_id: "s" as ServerId, project_id: "p" as ProjectId, session_id: "x" as SessionId }; +const event: Event = { event_id: "e" as EventId, type: "TASK_UPDATED", timestamp: "2026-01-01T00:00:00Z" as Timestamp, cursor: "current" as Cursor, context: scope, payload: {} }; +const task: Task = { ...scope, task_id: "t" as TaskId, capability_id: "c" as CapabilityId, state: "RUNNING", cancellable: true, created_at: event.timestamp, updated_at: event.timestamp }; + +describe("MemoryActivityAdapter", () => { + it("从有效游标之后恢复标准事件", async () => { + const received = vi.fn(); + new MemoryActivityAdapter([event]).subscribe(scope, undefined, received, vi.fn()); + await Promise.resolve(); + expect(received).toHaveBeenCalledWith(event); + }); + + it("游标失效时报告缺口但继续恢复", async () => { + const phase = vi.fn(); + new MemoryActivityAdapter([event]).subscribe(scope, "expired" as Cursor, vi.fn(), phase); + await Promise.resolve(); + expect(phase).toHaveBeenCalledWith("streaming", expect.objectContaining({ requested_cursor: "expired", resumed_cursor: "current" })); + }); + + it("断开订阅不会取消任务,取消必须显式调用", async () => { + const adapter = new MemoryActivityAdapter([event], [task]); + adapter.subscribe(scope, undefined, vi.fn(), vi.fn()).close(); + await expect(adapter.cancelTask(task)).resolves.toMatchObject({ state: "CANCELLED", cancellable: false }); + await expect(adapter.cancelTask({ ...task, state: "SUCCEEDED" })).rejects.toThrow("不可取消"); + }); +}); diff --git a/src/features/activity/memoryAdapter.ts b/src/features/activity/memoryAdapter.ts new file mode 100644 index 0000000..112cbdf --- /dev/null +++ b/src/features/activity/memoryAdapter.ts @@ -0,0 +1,34 @@ +import type { Cursor, Event, Task, Timestamp } from "../../protocol/model"; +import type { ActivityAdapter, ActivityScope, ActivitySubscription, ResumeGap, StreamPhase } from "./types"; + +export class MemoryActivityAdapter implements ActivityAdapter { + readonly #events: readonly Event[]; + readonly #tasks = new Map(); + + constructor(events: readonly Event[] = [], tasks: readonly Task[] = []) { + this.#events = events; + tasks.forEach((task) => this.#tasks.set(task.task_id, task)); + } + + subscribe(scope: ActivityScope, cursor: Cursor | undefined, onEvent: (event: Event) => void, onPhase: (phase: StreamPhase, gap?: ResumeGap) => void): ActivitySubscription { + let closed = false; + const scoped = this.#events.filter((event) => event.context.server_id === scope.server_id && event.context.project_id === scope.project_id); + const cursorIndex = cursor ? scoped.findIndex((event) => event.cursor === cursor) : -1; + const gap = cursor && cursorIndex < 0 ? { requested_cursor: cursor, resumed_cursor: scoped[0]?.cursor, message: "事件游标已失效,期间事件可能缺失。" } : undefined; + queueMicrotask(() => { + if (closed) return; + onPhase("streaming", gap); + scoped.slice(cursorIndex + 1).forEach((event) => { if (!closed) onEvent(event); }); + }); + return { close: () => { closed = true; } }; + } + + async cancelTask(task: Task): Promise { + if (!task.cancellable || ["SUCCEEDED", "FAILED", "CANCELLED"].includes(task.state)) { + throw new Error("该任务当前不可取消"); + } + const cancelled = { ...task, state: "CANCELLED" as const, cancellable: false, updated_at: new Date().toISOString() as Timestamp }; + this.#tasks.set(task.task_id, cancelled); + return cancelled; + } +} diff --git a/src/features/activity/state.test.ts b/src/features/activity/state.test.ts new file mode 100644 index 0000000..6ab141d --- /dev/null +++ b/src/features/activity/state.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { CapabilityId, Cursor, EventId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model"; +import { activityReducer, canCancelTask, filterEvents, initialActivityState, isTaskTransitionAllowed } from "./state"; + +const ids = { server_id: "server-1" as ServerId, project_id: "project-1" as ProjectId, session_id: "session-1" as SessionId }; +const task = (state: Task["state"], cancellable = true): Task => ({ ...ids, task_id: "task-1" as TaskId, capability_id: "shell" as CapabilityId, state, cancellable, created_at: "2026-01-01T00:00:00Z" as Timestamp, updated_at: "2026-01-01T00:00:01Z" as Timestamp }); +const event = (id: string, cursor: string, type = "TASK_UPDATED") => ({ event_id: id as EventId, type, timestamp: "2026-01-01T00:00:00Z" as Timestamp, cursor: cursor as Cursor, sequence: 1, context: ids, payload: {} }); + +describe("activity state", () => { + it("仅接受合法任务状态转换并保护终态", () => { + expect(isTaskTransitionAllowed("PENDING", "RUNNING")).toBe(true); + expect(isTaskTransitionAllowed("SUCCEEDED", "RUNNING")).toBe(false); + const finished = activityReducer(initialActivityState, { type: "task_received", task: task("SUCCEEDED") }); + expect(activityReducer(finished, { type: "task_received", task: task("RUNNING") })).toBe(finished); + }); + + it("只允许可取消的非终态任务取消", () => { + expect(canCancelTask(task("RUNNING"))).toBe(true); + expect(canCancelTask(task("RUNNING", false))).toBe(false); + expect(canCancelTask(task("FAILED"))).toBe(false); + }); + + it("记录最新事件游标、去重并保留恢复缺口", () => { + const once = activityReducer(initialActivityState, { type: "event_received", event: event("e1", "c1") }); + expect(activityReducer(once, { type: "event_received", event: event("e1", "c1") }).events).toHaveLength(1); + const resumed = activityReducer(once, { type: "resume_completed", cursor: "c3" as Cursor, gap: { requested_cursor: "c1" as Cursor, resumed_cursor: "c3" as Cursor, message: "缺口" } }); + expect(resumed).toMatchObject({ stream_phase: "streaming", last_cursor: "c3", gap: { message: "缺口" } }); + }); + + it("按类型和固定上下文筛选事件", () => { + expect(filterEvents([event("e1", "c1"), event("e2", "c2", "TASK_OUTPUT")], { types: ["TASK_OUTPUT"], project_id: ids.project_id })).toHaveLength(1); + }); +}); diff --git a/src/features/activity/state.ts b/src/features/activity/state.ts new file mode 100644 index 0000000..3f54121 --- /dev/null +++ b/src/features/activity/state.ts @@ -0,0 +1,66 @@ +import type { Event, Task, TaskState } from "../../protocol/model"; +import type { ActivityAction, ActivityState, EventFiltersValue } from "./types"; + +export const initialActivityState: ActivityState = { + tasks: [], outputs: {}, events: [], stream_phase: "idle", + follow_events: true, follow_output: true, +}; + +export const terminalTaskStates = new Set(["SUCCEEDED", "FAILED", "CANCELLED"]); + +export function canCancelTask(task: Task): boolean { + return task.cancellable && !terminalTaskStates.has(task.state); +} + +export function isTaskTransitionAllowed(from: TaskState, to: TaskState): boolean { + if (from === to) return true; + if (terminalTaskStates.has(from)) return false; + const allowed: Readonly> = { + PENDING: ["RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"], + RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"], + }; + return allowed[from]?.includes(to) ?? true; +} + +export function activityReducer(state: ActivityState, action: ActivityAction): ActivityState { + switch (action.type) { + case "task_received": { + const previous = state.tasks.find((task) => task.task_id === action.task.task_id); + if (previous && !isTaskTransitionAllowed(previous.state, action.task.state)) return state; + return { ...state, tasks: [...state.tasks.filter((task) => task.task_id !== action.task.task_id), action.task] }; + } + case "task_output_received": { + const key = action.output.task_id as string; + return { ...state, outputs: { ...state.outputs, [key]: [...(state.outputs[key] ?? []), action.output] } }; + } + case "event_received": + if (state.events.some((event) => event.event_id === action.event.event_id)) return state; + return { ...state, events: [...state.events, action.event], last_cursor: action.event.cursor }; + case "stream_phase_changed": + return { ...state, stream_phase: action.phase }; + case "resume_completed": + return { ...state, stream_phase: "streaming", last_cursor: action.cursor, gap: action.gap }; + case "select_task": return { ...state, selected_task_id: action.task_id }; + case "select_event": return { ...state, selected_event_id: action.event_id }; + case "set_follow_events": return { ...state, follow_events: action.enabled }; + case "set_follow_output": return { ...state, follow_output: action.enabled }; + } +} + +export function filterEvents(events: readonly Event[], filters: EventFiltersValue): readonly Event[] { + return events.filter((event) => + (!filters.types.length || filters.types.includes(event.type)) && + (!filters.project_id || event.context.project_id === filters.project_id) && + (!filters.session_id || event.context.session_id === filters.session_id) && + (!filters.from || event.timestamp >= filters.from) && + (!filters.to || event.timestamp <= filters.to) + ); +} + +export function taskDuration(task: Task, now = Date.now()): string { + const start = Date.parse(task.created_at); + const end = terminalTaskStates.has(task.state) ? Date.parse(task.updated_at) : now; + if (!Number.isFinite(start) || !Number.isFinite(end)) return "未知"; + const seconds = Math.max(0, Math.floor((end - start) / 1000)); + return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`; +} diff --git a/src/features/activity/types.ts b/src/features/activity/types.ts new file mode 100644 index 0000000..f525c24 --- /dev/null +++ b/src/features/activity/types.ts @@ -0,0 +1,74 @@ +import type { + Cursor, Event, EventContext, EventId, ProjectId, ServerId, SessionId, + Task, TaskId, Timestamp, +} from "../../protocol/model"; + +export interface TaskOutputEntry { + readonly id: string; + readonly task_id: TaskId; + readonly timestamp: Timestamp; + readonly text: string; + readonly redacted: boolean; +} + +export interface EventFiltersValue { + readonly types: readonly string[]; + readonly project_id?: ProjectId; + readonly session_id?: SessionId; + readonly from?: Timestamp; + readonly to?: Timestamp; +} + +export type StreamPhase = "idle" | "loading" | "streaming" | "degraded" | "reconnecting" | "recovery_failed" | "closed"; + +export interface ResumeGap { + readonly requested_cursor?: Cursor; + readonly resumed_cursor?: Cursor; + readonly message: string; +} + +export interface ActivityState { + readonly tasks: readonly Task[]; + readonly outputs: Readonly>; + readonly events: readonly Event[]; + readonly selected_task_id?: TaskId; + readonly selected_event_id?: EventId; + readonly stream_phase: StreamPhase; + readonly last_cursor?: Cursor; + readonly gap?: ResumeGap; + readonly follow_events: boolean; + readonly follow_output: boolean; +} + +export type ActivityAction = + | { readonly type: "task_received"; readonly task: Task } + | { readonly type: "task_output_received"; readonly output: TaskOutputEntry } + | { readonly type: "event_received"; readonly event: Event } + | { readonly type: "stream_phase_changed"; readonly phase: StreamPhase } + | { readonly type: "resume_completed"; readonly cursor: Cursor; readonly gap?: ResumeGap } + | { readonly type: "select_task"; readonly task_id?: TaskId } + | { readonly type: "select_event"; readonly event_id?: EventId } + | { readonly type: "set_follow_events"; readonly enabled: boolean } + | { readonly type: "set_follow_output"; readonly enabled: boolean }; + +export interface ActivityScope { + readonly server_id: ServerId; + readonly project_id: ProjectId; + readonly session_id?: SessionId; +} + +export interface ActivitySubscription { + close(): void; +} + +export interface ActivityAdapter { + subscribe(scope: ActivityScope, cursor: Cursor | undefined, onEvent: (event: Event) => void, onPhase: (phase: StreamPhase, gap?: ResumeGap) => void): ActivitySubscription; + cancelTask(task: Task): Promise; +} + +export function activityEvent(input: { + event_id: EventId; cursor: Cursor; timestamp: Timestamp; sequence: number; + type: Event["type"]; context: EventContext; payload: unknown; +}): Event { + return input; +}