Merge pull request 'GAZ-10: 实现 Task 与 Event 实时活动工作区' (#8) from agent/docker/7406f3ee into main

Reviewed-on: #8
This commit is contained in:
dev 2026-08-03 19:28:19 +00:00
commit 7d9bb5511c
9 changed files with 359 additions and 0 deletions

View File

@ -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 } }

View File

@ -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(<CancelTaskDialog task={task} open onConfirm={confirm} onClose={vi.fn()} />);
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(<TaskOutput follow entries={[{ id: "1", task_id: task.task_id, timestamp: task.updated_at, text: "token=[已隐藏]", redacted: true }]} onFollowChange={changed} onCopy={vi.fn()} />);
expect(screen.getAllByText(/已脱敏/).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "暂停跟随" }));
expect(changed).toHaveBeenCalledWith(false);
});
it("恢复缺口使用告警语义呈现", () => {
render(<ReconnectBanner phase="streaming" gap={{ message: "检测到事件缺口" }} />);
expect(screen.getByRole("alert")).toHaveTextContent("检测到事件缺口");
});
});

View File

@ -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<Record<string, string>> = {
PENDING: "等待中", RUNNING: "运行中", SUCCEEDED: "已成功", FAILED: "失败", CANCELLED: "已取消",
};
export function TaskStatus({ state }: { readonly state: Task["state"] }) {
return <span className={`activity-status activity-status--${state.toLowerCase()}`}><span aria-hidden="true" className="activity-status__mark" />{taskLabels[state] ?? state}</span>;
}
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 <div className="activity-empty" role="status"></div>;
if (!tasks.length) return <div className="activity-empty"></div>;
return <div className="activity-list" aria-label="任务列表">{tasks.map((task) => <button key={task.task_id} className="activity-list__row" aria-pressed={task.task_id === selectedTaskId} onClick={() => onSelect?.(task)}><span>{task.capability_id}</span><TaskStatus state={task.state} /><time>{taskDuration(task)}</time></button>)}</div>;
}
export function TaskTimeline({ task }: { readonly task: Task }) {
return <ol className="activity-timeline"><li><strong></strong><time>{task.created_at}</time></li>{task.state !== "PENDING" && <li><strong>{taskLabels[task.state] ?? task.state}</strong><time>{task.updated_at}</time></li>}</ol>;
}
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<HTMLDivElement>(null);
useEffect(() => { if (follow && outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; }, [entries, follow]);
const text = entries.map((entry) => entry.text).join("\n");
return <section className="activity-output" aria-label="任务输出"><header><span>{entries.some((entry) => entry.redacted) ? "已脱敏输出" : "标准输出"}</span><button onClick={() => (onCopy ? onCopy(text) : void navigator.clipboard?.writeText(text))}></button><button aria-pressed={follow} onClick={() => onFollowChange?.(!follow)}>{follow ? "暂停跟随" : "继续跟随"}</button></header><div ref={outputRef} className="activity-output__body" tabIndex={0}>{entries.length ? entries.map((entry) => <div key={entry.id}><time>{entry.timestamp}</time> {entry.text}{entry.redacted && <span className="activity-redacted"> </span>}</div>) : <span></span>}</div></section>;
}
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 <div className="activity-empty"></div>;
return <article className="activity-detail"><header><div><h2>{task.capability_id}</h2><small>{task.task_id}</small></div><TaskStatus state={task.state} /></header><dl><dt></dt><dd>{taskDuration(task)}</dd><dt></dt><dd>{task.server_id} / {task.project_id} / {task.session_id}</dd></dl>{task.result !== undefined && <pre className="activity-result">{JSON.stringify(task.result, null, 2)}</pre>}<TaskTimeline task={task} /><TaskOutput entries={outputs} follow={followOutput} onFollowChange={onFollowOutputChange} /><button className="activity-danger" disabled={!canCancelTask(task)} onClick={() => onCancel?.(task)}></button></article>;
}
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<HTMLButtonElement>(null);
useEffect(() => { if (open) cancelRef.current?.focus(); }, [open]);
if (!open || !task) return null;
return <div className="activity-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><div role="dialog" aria-modal="true" aria-labelledby="cancel-title" className="activity-dialog" onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}><h2 id="cancel-title"></h2><p></p><dl><dt>Teamserver</dt><dd>{task.server_id}</dd><dt>Project</dt><dd>{task.project_id}</dd><dt>Session</dt><dd>{task.session_id}</dd><dt>Task</dt><dd>{task.task_id}</dd></dl>{error && <p role="alert">{error}</p>}<footer><button ref={cancelRef} onClick={onClose}></button><button className="activity-danger" disabled={busy || !canCancelTask(task)} onClick={() => onConfirm(task)}>{busy ? "正在取消…" : "确认取消"}</button></footer></div></div>;
}
export function EventFilters({ value, knownTypes, onChange }: { readonly value: EventFiltersValue; readonly knownTypes: readonly string[]; readonly onChange: (value: EventFiltersValue) => void }) {
return <form className="activity-filters" onSubmit={(event: FormEvent) => event.preventDefault()}><label><select multiple value={[...value.types]} onChange={(event) => onChange({ ...value, types: Array.from(event.currentTarget.selectedOptions, (option) => option.value) })}>{knownTypes.map((type) => <option key={type}>{type}</option>)}</select></label><label><input type="datetime-local" onChange={(event) => onChange({ ...value, from: event.currentTarget.value ? `${event.currentTarget.value}:00.000Z` as EventFiltersValue["from"] : undefined })} /></label><button type="button" onClick={() => onChange({ types: [] })}></button></form>;
}
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<HTMLDivElement>(null);
useEffect(() => { if (follow && endRef.current?.parentElement) endRef.current.parentElement.scrollTop = endRef.current.parentElement.scrollHeight; }, [events, follow]);
return <section className="activity-events"><header><strong></strong><button aria-pressed={follow} onClick={() => onFollowChange?.(!follow)}>{follow ? "暂停跟随" : "继续跟随"}</button></header>{loading ? <div className="activity-empty"></div> : !events.length ? <div className="activity-empty"></div> : <div className="activity-events__feed">{events.map((event, index) => <button className={index === events.length - 1 ? "activity-event activity-event--new" : "activity-event"} key={event.event_id} onClick={() => onSelect?.(event)}><time>{event.timestamp}</time><strong>{event.type}</strong><span> {event.sequence ?? "—"} · {event.cursor}</span></button>)}<div ref={endRef} /></div>}</section>;
}
export function EventDetail({ event }: { readonly event?: Event }) {
if (!event) return <div className="activity-empty"></div>;
return <article className="activity-detail"><header><h2>{event.type}</h2><time>{event.timestamp}</time></header><dl><dt> ID</dt><dd>{event.event_id}</dd><dt> / </dt><dd>{event.sequence ?? "—"} / {event.cursor}</dd><dt></dt><dd>{event.context.server_id} / {event.context.project_id ?? "—"} / {event.context.session_id ?? "—"}</dd></dl><pre className="activity-result">{JSON.stringify(event.payload, null, 2)}</pre></article>;
}
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<Record<string, string>> = { loading: "正在建立事件流…", degraded: "事件流已降级,任务仍在服务端继续运行。", reconnecting: "连接中断,正在从最近游标恢复…", recovery_failed: "无法恢复事件流,请重新同步。" };
return <div role={phase === "recovery_failed" || gap ? "alert" : "status"} className="activity-banner"><span>{gap?.message ?? messages[phase] ?? phase}</span>{(phase === "recovery_failed" || phase === "degraded") && <button onClick={onReconnect}></button>}</div>;
}

View File

@ -0,0 +1,4 @@
export * from "./types";
export * from "./state";
export * from "./memoryAdapter";
export * from "./components";

View File

@ -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("不可取消");
});
});

View File

@ -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<string, Task>();
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<Task> {
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;
}
}

View File

@ -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);
});
});

View File

@ -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<string>(["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<Record<string, readonly string[]>> = {
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}`;
}

View File

@ -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<Record<string, readonly TaskOutputEntry[]>>;
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<Task>;
}
export function activityEvent(input: {
event_id: EventId; cursor: Cursor; timestamp: Timestamp; sequence: number;
type: Event["type"]; context: EventContext; payload: unknown;
}): Event {
return input;
}