Merge pull request 'GAZ-10: 实现 Task 与 Event 实时活动工作区' (#8) from agent/docker/7406f3ee into main
Reviewed-on: #8
This commit is contained in:
commit
7d9bb5511c
20
src/features/activity/activity.css
Normal file
20
src/features/activity/activity.css
Normal 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 } }
|
||||
35
src/features/activity/components.test.tsx
Normal file
35
src/features/activity/components.test.tsx
Normal 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("检测到事件缺口");
|
||||
});
|
||||
});
|
||||
63
src/features/activity/components.tsx
Normal file
63
src/features/activity/components.tsx
Normal 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>;
|
||||
}
|
||||
4
src/features/activity/index.ts
Normal file
4
src/features/activity/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./state";
|
||||
export * from "./memoryAdapter";
|
||||
export * from "./components";
|
||||
30
src/features/activity/memoryAdapter.test.ts
Normal file
30
src/features/activity/memoryAdapter.test.ts
Normal 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("不可取消");
|
||||
});
|
||||
});
|
||||
34
src/features/activity/memoryAdapter.ts
Normal file
34
src/features/activity/memoryAdapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/features/activity/state.test.ts
Normal file
33
src/features/activity/state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
src/features/activity/state.ts
Normal file
66
src/features/activity/state.ts
Normal 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} 秒`;
|
||||
}
|
||||
74
src/features/activity/types.ts
Normal file
74
src/features/activity/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user