Archived
feat: show project and workspace activity indicators
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { WorkspaceActivityResponse } from "../../shared/apiTypes.js";
|
||||
|
||||
export interface WorkspaceActivityRouteService {
|
||||
snapshot(): WorkspaceActivityResponse;
|
||||
}
|
||||
|
||||
export function registerWorkspaceActivityRoutes(app: FastifyInstance, activity: WorkspaceActivityRouteService, prefix = ""): void {
|
||||
app.get(`${prefix}/activity`, () => activity.snapshot());
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RealtimeEvent, SessionStatus } from "../../shared/apiTypes";
|
||||
import { WorkspaceActivityService } from "./workspaceActivityService";
|
||||
|
||||
function status(patch: Partial<SessionStatus> = {}): SessionStatus {
|
||||
return {
|
||||
sessionId: "s1",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceActivityService", () => {
|
||||
it("publishes and snapshots session activity by cwd", () => {
|
||||
const events: RealtimeEvent[] = [];
|
||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||
|
||||
service.applySessionStatus("/repo", status({ isStreaming: true }));
|
||||
|
||||
expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false }]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false } });
|
||||
|
||||
service.applySessionStatus("/repo", status({ isStreaming: false }));
|
||||
|
||||
expect(service.snapshot().workspaces).toEqual([]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
||||
});
|
||||
|
||||
it("combines sessions and terminals and clears closed terminals", () => {
|
||||
const events: RealtimeEvent[] = [];
|
||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||
|
||||
service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" });
|
||||
service.updateTerminal({ id: "t1", cwd: "/repo", exited: false });
|
||||
|
||||
expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: true }]);
|
||||
|
||||
service.removeSession("s1");
|
||||
service.updateTerminal({ id: "t1", cwd: "/repo", exited: true });
|
||||
|
||||
expect(service.snapshot().workspaces).toEqual([]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isSessionActive, isWorkspaceActivityActive } from "../../shared/activity.js";
|
||||
import type { RealtimeEvent, SessionActivity, SessionStatus, TerminalInfo, WorkspaceActivity, WorkspaceActivityResponse } from "../../shared/apiTypes.js";
|
||||
|
||||
export interface WorkspaceActivityPublisher {
|
||||
publishRealtime(event: RealtimeEvent): void;
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
cwd: string;
|
||||
status?: SessionStatus;
|
||||
activity?: SessionActivity;
|
||||
}
|
||||
|
||||
interface TerminalRecord {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export class WorkspaceActivityService {
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
constructor(private readonly publisher?: WorkspaceActivityPublisher) {}
|
||||
|
||||
applySessionStatus(cwd: string, status: SessionStatus): void {
|
||||
const previousCwd = this.sessions.get(status.sessionId)?.cwd;
|
||||
const record = this.sessions.get(status.sessionId) ?? { cwd };
|
||||
record.cwd = cwd;
|
||||
record.status = status;
|
||||
this.sessions.set(status.sessionId, record);
|
||||
this.pruneIdleSession(status.sessionId);
|
||||
this.publishChangedCwds(previousCwd, cwd);
|
||||
}
|
||||
|
||||
applySessionActivity(cwd: string, activity: SessionActivity): void {
|
||||
const previousCwd = this.sessions.get(activity.sessionId)?.cwd;
|
||||
const record = this.sessions.get(activity.sessionId) ?? { cwd };
|
||||
record.cwd = cwd;
|
||||
record.activity = activity;
|
||||
this.sessions.set(activity.sessionId, record);
|
||||
this.pruneIdleSession(activity.sessionId);
|
||||
this.publishChangedCwds(previousCwd, cwd);
|
||||
}
|
||||
|
||||
removeSession(sessionId: string): void {
|
||||
const cwd = this.sessions.get(sessionId)?.cwd;
|
||||
this.sessions.delete(sessionId);
|
||||
this.publishCwd(cwd);
|
||||
}
|
||||
|
||||
updateTerminal(terminal: Pick<TerminalInfo, "id" | "cwd" | "exited">): void {
|
||||
const previousCwd = this.terminals.get(terminal.id)?.cwd;
|
||||
if (terminal.exited) this.terminals.delete(terminal.id);
|
||||
else this.terminals.set(terminal.id, { cwd: terminal.cwd });
|
||||
this.publishChangedCwds(previousCwd, terminal.cwd);
|
||||
}
|
||||
|
||||
removeTerminal(terminalId: string, cwd?: string): void {
|
||||
const previousCwd = this.terminals.get(terminalId)?.cwd ?? cwd;
|
||||
this.terminals.delete(terminalId);
|
||||
this.publishCwd(previousCwd);
|
||||
}
|
||||
|
||||
snapshot(): WorkspaceActivityResponse {
|
||||
return {
|
||||
workspaces: this.activeCwds().map((cwd) => this.summaryForCwd(cwd)).filter(isWorkspaceActivityActive),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private pruneIdleSession(sessionId: string): void {
|
||||
const record = this.sessions.get(sessionId);
|
||||
if (record !== undefined && !isSessionActive(record.status, record.activity)) this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
private publishChangedCwds(previousCwd: string | undefined, cwd: string): void {
|
||||
this.publishCwd(previousCwd);
|
||||
if (previousCwd !== cwd) this.publishCwd(cwd);
|
||||
}
|
||||
|
||||
private publishCwd(cwd: string | undefined): void {
|
||||
if (cwd === undefined || cwd === "") return;
|
||||
this.publisher?.publishRealtime({ type: "workspace.activity", activity: this.summaryForCwd(cwd) });
|
||||
}
|
||||
|
||||
private activeCwds(): string[] {
|
||||
const cwds = new Set<string>();
|
||||
for (const record of this.sessions.values()) {
|
||||
if (isSessionActive(record.status, record.activity)) cwds.add(record.cwd);
|
||||
}
|
||||
for (const record of this.terminals.values()) cwds.add(record.cwd);
|
||||
return [...cwds].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
private summaryForCwd(cwd: string): WorkspaceActivity {
|
||||
return {
|
||||
cwd,
|
||||
hasSessionActivity: [...this.sessions.values()].some((record) => record.cwd === cwd && isSessionActive(record.status, record.activity)),
|
||||
hasTerminalActivity: [...this.terminals.values()].some((terminal) => terminal.cwd === cwd),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { WorkspaceActivityService } from "./activity/workspaceActivityService.js";
|
||||
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
|
||||
import { SessionEventHub } from "./realtime/sessionEventHub.js";
|
||||
import { AuthService } from "./sessions/authService.js";
|
||||
import { registerAuthRoutes } from "./sessions/authRoutes.js";
|
||||
@@ -17,10 +19,12 @@ const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||
const auth = new AuthService();
|
||||
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry });
|
||||
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, workspaceActivity });
|
||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||
const terminals = new TerminalService(eventHub);
|
||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||
registerWorkspaceActivityRoutes(app, workspaceActivity);
|
||||
registerAuthRoutes(app, auth);
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
registerTerminalRoutes(app, terminals);
|
||||
|
||||
@@ -30,6 +30,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
|
||||
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
||||
});
|
||||
|
||||
app.all("/api/activity", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/auth", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/sessions", (request, reply) => proxy(request, reply));
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
@@ -163,6 +164,7 @@ export interface PiSessionServiceDependencies {
|
||||
createAgentRuntime?: CreateAgentRuntime;
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
heartbeatIntervalMs?: number;
|
||||
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession">;
|
||||
}
|
||||
|
||||
export class PiSessionService {
|
||||
@@ -177,6 +179,7 @@ export class PiSessionService {
|
||||
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
|
||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||
private readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession"> | undefined;
|
||||
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
@@ -185,6 +188,7 @@ export class PiSessionService {
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
this.workspaceActivity = deps.workspaceActivity;
|
||||
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
||||
this.commandService = new SessionCommandService(
|
||||
(sessionId) => this.getActive(sessionId),
|
||||
@@ -215,6 +219,7 @@ export class PiSessionService {
|
||||
this.authLossWarnings.clear();
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
this.workspaceActivity?.removeSession(active.runtime.session.sessionId);
|
||||
await active.runtime.session.abort();
|
||||
await active.runtime.dispose();
|
||||
}));
|
||||
@@ -466,6 +471,7 @@ export class PiSessionService {
|
||||
if (!active) return;
|
||||
this.active.delete(sessionId);
|
||||
this.activities.delete(sessionId);
|
||||
this.workspaceActivity?.removeSession(sessionId);
|
||||
this.clearAuthLossWarningsForSession(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
active.unsubscribe();
|
||||
@@ -638,12 +644,14 @@ export class PiSessionService {
|
||||
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
||||
this.activities.set(session.sessionId, stored);
|
||||
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
|
||||
this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
|
||||
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
||||
this.events.publishGlobal({ type: "activity.update", activity });
|
||||
}
|
||||
|
||||
private publishStatus(session: PiAgentSession): void {
|
||||
const status = this.statusFromSession(session);
|
||||
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
|
||||
this.events.publish(session.sessionId, { type: "status.update", status });
|
||||
this.events.publishGlobal({ type: "status.update", status });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import type { TerminalUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
const MAX_REPLAY_BUFFER = 200_000;
|
||||
|
||||
@@ -24,7 +25,7 @@ interface TerminalRecord extends TerminalInfo {
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
constructor(private readonly events?: SessionEventHub) {}
|
||||
constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal">) {}
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
return [...this.terminals.values()]
|
||||
@@ -63,10 +64,13 @@ export class TerminalService {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
this.publish({ type: "terminal.exited", terminal: toInfo(record) });
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.exited", terminal: info });
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
@@ -107,6 +111,7 @@ export class TerminalService {
|
||||
if (terminal === undefined) return;
|
||||
this.terminals.delete(id);
|
||||
terminal.events.removeAllListeners();
|
||||
this.workspaceActivity?.removeTerminal(id, terminal.cwd);
|
||||
if (!terminal.exited) terminal.pty.kill();
|
||||
this.publish({ type: "terminal.closed", terminalId: id, cwd: terminal.cwd });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user