test(KB-323): add end-to-end verification test for steering comments injection
- Add comprehensive e2e test for steering comments injection in executor - Verify system prompt steering comments are correctly injected - Test covers executor initialization and prompt construction
This commit is contained in:
598
packages/core/src/agent-store.ts
Normal file
598
packages/core/src/agent-store.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* AgentStore - Filesystem-based persistence for agent lifecycle management
|
||||
*
|
||||
* Agents are stored at `.kb/agents/{agentId}.json` with their metadata.
|
||||
* Heartbeat events are appended to `.kb/agents/{agentId}-heartbeats.jsonl`.
|
||||
*
|
||||
* File Structure:
|
||||
* - agents/{agentId}.json: Agent metadata (id, name, role, state, taskId, timestamps, metadata)
|
||||
* - agents/{agentId}-heartbeats.jsonl: Append-only heartbeat events
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
Agent,
|
||||
AgentState,
|
||||
AgentCapability,
|
||||
AgentCreateInput,
|
||||
AgentUpdateInput,
|
||||
AgentHeartbeatEvent,
|
||||
AgentHeartbeatRun,
|
||||
AgentDetail,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
|
||||
/** Events emitted by AgentStore */
|
||||
export interface AgentStoreEvents {
|
||||
/** Emitted when an agent is created */
|
||||
"agent:created": (agent: Agent) => void;
|
||||
/** Emitted when an agent is updated */
|
||||
"agent:updated": (agent: Agent, previousState?: AgentState) => void;
|
||||
/** Emitted when an agent is deleted */
|
||||
"agent:deleted": (agentId: string) => void;
|
||||
/** Emitted when a heartbeat is recorded */
|
||||
"agent:heartbeat": (agentId: string, event: AgentHeartbeatEvent) => void;
|
||||
/** Emitted when an agent state changes */
|
||||
"agent:stateChanged": (agentId: string, from: AgentState, to: AgentState) => void;
|
||||
}
|
||||
|
||||
type TypedEventEmitter<Events extends Record<string, unknown[]>> = {
|
||||
[K in keyof Events]: {
|
||||
emit(event: K, ...args: Events[K]): boolean;
|
||||
on(event: K, listener: (...args: Events[K]) => void): TypedEventEmitter<Events>;
|
||||
once(event: K, listener: (...args: Events[K]) => void): TypedEventEmitter<Events>;
|
||||
off(event: K, listener: (...args: Events[K]) => void): TypedEventEmitter<Events>;
|
||||
};
|
||||
}[keyof Events] & EventEmitter;
|
||||
|
||||
/** Options for AgentStore constructor */
|
||||
export interface AgentStoreOptions {
|
||||
/** Root directory for kb data (default: .kb) */
|
||||
rootDir?: string;
|
||||
}
|
||||
|
||||
/** Agent data as stored on disk */
|
||||
interface AgentData {
|
||||
id: string;
|
||||
name: string;
|
||||
role: AgentCapability;
|
||||
state: AgentState;
|
||||
taskId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastHeartbeatAt?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-agent write lock for serialization */
|
||||
interface AgentLock {
|
||||
promise: Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentStore manages agent lifecycle with filesystem-based persistence.
|
||||
* Follows the same patterns as TaskStore for consistency.
|
||||
*/
|
||||
export class AgentStore extends EventEmitter {
|
||||
private rootDir: string;
|
||||
private agentsDir: string;
|
||||
private locks: Map<string, AgentLock> = new Map();
|
||||
|
||||
constructor(options: AgentStoreOptions = {}) {
|
||||
super();
|
||||
this.rootDir = options.rootDir ?? ".kb";
|
||||
this.agentsDir = join(this.rootDir, "agents");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store by creating necessary directories.
|
||||
* Should be called before other operations.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await mkdir(this.agentsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent with "idle" state.
|
||||
* @param input - Creation parameters
|
||||
* @returns The created agent
|
||||
* @throws Error if input is invalid
|
||||
*/
|
||||
async createAgent(input: AgentCreateInput): Promise<Agent> {
|
||||
if (!input.name?.trim()) {
|
||||
throw new Error("Agent name is required");
|
||||
}
|
||||
if (!input.role) {
|
||||
throw new Error("Agent role is required");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const agentId = `agent-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const agent: Agent = {
|
||||
id: agentId,
|
||||
name: input.name.trim(),
|
||||
role: input.role,
|
||||
state: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: input.metadata ?? {},
|
||||
};
|
||||
|
||||
await this.writeAgent(agent);
|
||||
this.emit("agent:created", agent);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an agent by ID.
|
||||
* @param agentId - The agent ID
|
||||
* @returns The agent, or null if not found
|
||||
*/
|
||||
async getAgent(agentId: string): Promise<Agent | null> {
|
||||
try {
|
||||
const data = await this.readAgentFile(agentId);
|
||||
return this.parseAgent(data);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed agent info including heartbeat history.
|
||||
* @param agentId - The agent ID
|
||||
* @param heartbeatLimit - Max number of heartbeat events to return (default: 50)
|
||||
* @returns Agent detail, or null if not found
|
||||
*/
|
||||
async getAgentDetail(agentId: string, heartbeatLimit = 50): Promise<AgentDetail | null> {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) return null;
|
||||
|
||||
const [history, activeRun, completedRuns] = await Promise.all([
|
||||
this.getHeartbeatHistory(agentId, heartbeatLimit),
|
||||
this.getActiveHeartbeatRun(agentId),
|
||||
this.getCompletedHeartbeatRuns(agentId),
|
||||
]);
|
||||
|
||||
return {
|
||||
...agent,
|
||||
heartbeatHistory: history,
|
||||
activeRun: activeRun ?? undefined,
|
||||
completedRuns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an agent with partial updates.
|
||||
* @param agentId - The agent ID
|
||||
* @param updates - Fields to update
|
||||
* @returns The updated agent
|
||||
* @throws Error if agent not found
|
||||
*/
|
||||
async updateAgent(agentId: string, updates: AgentUpdateInput): Promise<Agent> {
|
||||
return this.withLock(agentId, async () => {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
name: updates.name?.trim() ?? agent.name,
|
||||
role: updates.role ?? agent.role,
|
||||
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
this.emit("agent:updated", updated);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an agent's state with validation.
|
||||
* @param agentId - The agent ID
|
||||
* @param newState - The target state
|
||||
* @returns The updated agent
|
||||
* @throws Error if transition is invalid or agent not found
|
||||
*/
|
||||
async updateAgentState(agentId: string, newState: AgentState): Promise<Agent> {
|
||||
return this.withLock(agentId, async () => {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const currentState = agent.state;
|
||||
|
||||
// Validate transition
|
||||
if (currentState === newState) {
|
||||
return agent; // No change needed
|
||||
}
|
||||
|
||||
if (currentState === "terminated") {
|
||||
throw new Error(`Cannot transition from terminated state to ${newState}`);
|
||||
}
|
||||
|
||||
const validTransitions = AGENT_VALID_TRANSITIONS[currentState];
|
||||
if (!validTransitions.includes(newState)) {
|
||||
throw new Error(
|
||||
`Invalid state transition: ${currentState} -> ${newState}. Valid transitions: ${validTransitions.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
state: newState,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
this.emit("agent:stateChanged", agentId, currentState, newState);
|
||||
this.emit("agent:updated", updated, currentState);
|
||||
|
||||
// Handle heartbeat run lifecycle
|
||||
if (newState === "active" && !agent.lastHeartbeatAt) {
|
||||
// Starting first activity - start a heartbeat run
|
||||
await this.startHeartbeatRun(agentId);
|
||||
} else if (newState === "terminated") {
|
||||
// End the active run if any
|
||||
const activeRun = await this.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
await this.endHeartbeatRun(activeRun.id, "terminated");
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a task to an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @param taskId - The task ID to assign, or undefined to unassign
|
||||
* @returns The updated agent
|
||||
*/
|
||||
async assignTask(agentId: string, taskId: string | undefined): Promise<Agent> {
|
||||
return this.withLock(agentId, async () => {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
taskId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
this.emit("agent:updated", updated);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents, optionally filtered by state.
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of agents
|
||||
*/
|
||||
async listAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
|
||||
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
|
||||
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats"));
|
||||
|
||||
const agents: Agent[] = [];
|
||||
for (const file of agentFiles) {
|
||||
try {
|
||||
const data = await this.readAgentFile(file.replace(".json", ""));
|
||||
const agent = this.parseAgent(data);
|
||||
|
||||
// Apply filters
|
||||
if (filter?.state && agent.state !== filter.state) continue;
|
||||
if (filter?.role && agent.role !== filter.role) continue;
|
||||
|
||||
agents.push(agent);
|
||||
} catch {
|
||||
// Skip corrupted files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by createdAt desc
|
||||
return agents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent and its heartbeat history.
|
||||
* @param agentId - The agent ID
|
||||
* @throws Error if agent not found
|
||||
*/
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.withLock(agentId, async () => {
|
||||
const agentPath = join(this.agentsDir, `${agentId}.json`);
|
||||
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
|
||||
|
||||
// Verify agent exists
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// Delete files
|
||||
await unlink(agentPath).catch(() => {});
|
||||
await unlink(heartbeatPath).catch(() => {});
|
||||
|
||||
this.emit("agent:deleted", agentId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a heartbeat event for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @param status - Heartbeat status
|
||||
* @param runId - Optional run ID (uses active run if not provided)
|
||||
* @returns The recorded heartbeat event
|
||||
*/
|
||||
async recordHeartbeat(
|
||||
agentId: string,
|
||||
status: AgentHeartbeatEvent["status"],
|
||||
runId?: string
|
||||
): Promise<AgentHeartbeatEvent> {
|
||||
return this.withLock(agentId, async () => {
|
||||
// Verify agent exists
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// Get or determine run ID
|
||||
let effectiveRunId = runId;
|
||||
if (!effectiveRunId) {
|
||||
const activeRun = await this.getActiveHeartbeatRun(agentId);
|
||||
effectiveRunId = activeRun?.id ?? `run-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
const event: AgentHeartbeatEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status,
|
||||
runId: effectiveRunId,
|
||||
};
|
||||
|
||||
// Append to heartbeat log
|
||||
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
await writeFile(heartbeatPath, line, { flag: "a" });
|
||||
|
||||
// Update agent's lastHeartbeatAt if status is ok
|
||||
if (status === "ok") {
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
lastHeartbeatAt: event.timestamp,
|
||||
updatedAt: event.timestamp,
|
||||
};
|
||||
await this.writeAgent(updated);
|
||||
}
|
||||
|
||||
this.emit("agent:heartbeat", agentId, event);
|
||||
|
||||
return event;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heartbeat history for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @param limit - Maximum number of events to return (default: 50)
|
||||
* @returns Array of heartbeat events (newest first)
|
||||
*/
|
||||
async getHeartbeatHistory(agentId: string, limit = 50): Promise<AgentHeartbeatEvent[]> {
|
||||
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
|
||||
|
||||
if (!existsSync(heartbeatPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(heartbeatPath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
|
||||
// Parse events and reverse (newest first)
|
||||
const events: AgentHeartbeatEvent[] = lines
|
||||
.map((line) => JSON.parse(line) as AgentHeartbeatEvent)
|
||||
.reverse()
|
||||
.slice(0, limit);
|
||||
|
||||
return events;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new heartbeat run for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @returns The created run
|
||||
*/
|
||||
async startHeartbeatRun(agentId: string): Promise<AgentHeartbeatRun> {
|
||||
const runId = `run-${randomUUID().slice(0, 8)}`;
|
||||
const run: AgentHeartbeatRun = {
|
||||
id: runId,
|
||||
agentId,
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
// Record as heartbeat event to track runs
|
||||
await this.recordHeartbeat(agentId, "ok", runId);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a heartbeat run.
|
||||
* @param runId - The run ID
|
||||
* @param status - End status (completed or terminated)
|
||||
*/
|
||||
async endHeartbeatRun(runId: string, status: "completed" | "terminated"): Promise<void> {
|
||||
// Find the agent for this run by scanning heartbeat files
|
||||
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
|
||||
const heartbeatFiles = files.filter((f) => f.endsWith("-heartbeats.jsonl"));
|
||||
|
||||
for (const file of heartbeatFiles) {
|
||||
const agentId = file.replace("-heartbeats.jsonl", "");
|
||||
const history = await this.getHeartbeatHistory(agentId, 1000);
|
||||
|
||||
// Check if this run exists in the history
|
||||
const hasRun = history.some((h) => h.runId === runId);
|
||||
if (hasRun) {
|
||||
// Record end as special event
|
||||
await this.recordHeartbeat(agentId, status === "terminated" ? "missed" : "ok", runId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active heartbeat run for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @returns The active run, or null if none
|
||||
*/
|
||||
async getActiveHeartbeatRun(agentId: string): Promise<AgentHeartbeatRun | null> {
|
||||
const history = await this.getHeartbeatHistory(agentId, 100);
|
||||
|
||||
// Find the most recent run that started but hasn't ended
|
||||
// A run is considered ended if there's a terminal state transition
|
||||
const runs = new Map<string, AgentHeartbeatRun>();
|
||||
|
||||
for (const event of history) {
|
||||
if (!runs.has(event.runId)) {
|
||||
runs.set(event.runId, {
|
||||
id: event.runId,
|
||||
agentId,
|
||||
startedAt: event.timestamp,
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
// Update based on event status
|
||||
const run = runs.get(event.runId)!;
|
||||
if (event.status === "missed") {
|
||||
run.endedAt = event.timestamp;
|
||||
run.status = "terminated";
|
||||
}
|
||||
}
|
||||
|
||||
// Return the most recent active run
|
||||
for (const run of runs.values()) {
|
||||
if (run.status === "active") {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all completed heartbeat runs for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @returns Array of completed runs
|
||||
*/
|
||||
async getCompletedHeartbeatRuns(agentId: string): Promise<AgentHeartbeatRun[]> {
|
||||
const history = await this.getHeartbeatHistory(agentId, 1000);
|
||||
const runs = new Map<string, AgentHeartbeatRun>();
|
||||
|
||||
for (const event of history) {
|
||||
if (!runs.has(event.runId)) {
|
||||
runs.set(event.runId, {
|
||||
id: event.runId,
|
||||
agentId,
|
||||
startedAt: event.timestamp,
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
const run = runs.get(event.runId)!;
|
||||
if (event.status === "missed") {
|
||||
run.endedAt = event.timestamp;
|
||||
run.status = "terminated";
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(runs.values()).filter((r) => r.status !== "active");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async readAgentFile(agentId: string): Promise<AgentData> {
|
||||
const path = join(this.agentsDir, `${agentId}.json`);
|
||||
const content = await readFile(path, "utf-8");
|
||||
return JSON.parse(content) as AgentData;
|
||||
}
|
||||
|
||||
private parseAgent(data: AgentData): Agent {
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
state: data.state,
|
||||
taskId: data.taskId,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt,
|
||||
metadata: data.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private async writeAgent(agent: Agent): Promise<void> {
|
||||
const path = join(this.agentsDir, `${agent.id}.json`);
|
||||
const data: AgentData = {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
state: agent.state,
|
||||
taskId: agent.taskId,
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
lastHeartbeatAt: agent.lastHeartbeatAt,
|
||||
metadata: agent.metadata,
|
||||
};
|
||||
|
||||
// Write atomically using temp file
|
||||
const tempPath = `${path}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, JSON.stringify(data, null, 2));
|
||||
|
||||
// Rename temp file to final path (atomic on most filesystems)
|
||||
const { rename } = await import("node:fs/promises");
|
||||
await rename(tempPath, path);
|
||||
}
|
||||
|
||||
private async withLock<T>(agentId: string, fn: () => Promise<T>): Promise<T> {
|
||||
// Get or create lock for this agent
|
||||
let lock = this.locks.get(agentId);
|
||||
if (!lock) {
|
||||
lock = { promise: Promise.resolve() };
|
||||
this.locks.set(agentId, lock);
|
||||
}
|
||||
|
||||
// Chain operations
|
||||
const operation = lock.promise.then(fn, fn);
|
||||
lock.promise = operation;
|
||||
|
||||
return operation as Promise<T>;
|
||||
}
|
||||
}
|
||||
2236
packages/core/src/store.ts.backup
Normal file
2236
packages/core/src/store.ts.backup
Normal file
File diff suppressed because it is too large
Load Diff
50
packages/core/src/store.ts.new
Normal file
50
packages/core/src/store.ts.new
Normal file
@@ -0,0 +1,50 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
"task:created": [task: Task];
|
||||
"task:moved": [data: { task: Task; from: Column; to: Column }];
|
||||
"task:updated": [task: Task];
|
||||
"task:deleted": [task: Task];
|
||||
"task:merged": [result: MergeResult];
|
||||
"settings:updated": [data: { settings: Settings; previous: Settings }];
|
||||
"agent:log": [entry: AgentLogEntry];
|
||||
}
|
||||
|
||||
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private kbDir: string;
|
||||
private tasksDir: string;
|
||||
private configPath: string;
|
||||
private archiveLogPath: string;
|
||||
private activityLogPath: string;
|
||||
|
||||
/** File-system watcher instance */
|
||||
private watcher: FSWatcher | null = null;
|
||||
/** In-memory cache of tasks for diffing watcher events */
|
||||
private taskCache: Map<string, Task> = new Map();
|
||||
/** Paths recently written by in-process mutations (suppresses duplicate events) */
|
||||
private recentlyWritten: Set<string> = new Set();
|
||||
/** Pending debounce timers keyed by task ID */
|
||||
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
/** Debounce interval in ms */
|
||||
private debounceMs = 150;
|
||||
/** Per-task promise chain for serializing writes */
|
||||
private taskLocks: Map<string, Promise<void>> = new Map();
|
||||
/** Promise chain for serializing config.json read-modify-write cycles */
|
||||
private configLock: Promise<void> = Promise.resolve();
|
||||
/** Global settings store (`~/.pi/kb/settings.json`) */
|
||||
private globalSettingsStore: GlobalSettingsStore;
|
||||
|
||||
constructor(private rootDir: string, globalSettingsDir?: string) {
|
||||
super();
|
||||
this.setMaxListeners(100);
|
||||
this.kbDir = join(rootDir, ".kb");
|
||||
this.tasksDir = join(this.kbDir, "tasks");
|
||||
this.configPath = join(this.kbDir, "config.json");
|
||||
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
|
||||
@@ -833,3 +833,90 @@ export interface PlanningSession {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ── Agent Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Agent lifecycle states */
|
||||
export const AGENT_STATES = ["idle", "active", "paused", "terminated"] as const;
|
||||
export type AgentState = (typeof AGENT_STATES)[number];
|
||||
|
||||
/** Valid state transitions for agents */
|
||||
export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
|
||||
idle: ["active"],
|
||||
active: ["paused", "terminated"],
|
||||
paused: ["active", "terminated"],
|
||||
terminated: [], // Terminal state - no exits
|
||||
};
|
||||
|
||||
/** Single heartbeat event recorded for an agent */
|
||||
export interface AgentHeartbeatEvent {
|
||||
/** ISO-8601 timestamp of when the heartbeat was recorded */
|
||||
timestamp: string;
|
||||
/** Status of the heartbeat */
|
||||
status: "ok" | "missed" | "recovered";
|
||||
/** ID of the heartbeat run this event belongs to */
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/** A continuous heartbeat session/run for an agent */
|
||||
export interface AgentHeartbeatRun {
|
||||
/** Unique identifier for this run */
|
||||
id: string;
|
||||
/** ID of the agent this run belongs to */
|
||||
agentId: string;
|
||||
/** ISO-8601 timestamp when the run started */
|
||||
startedAt: string;
|
||||
/** ISO-8601 timestamp when the run ended (null if active) */
|
||||
endedAt: string | null;
|
||||
/** Status of the run */
|
||||
status: "active" | "completed" | "terminated";
|
||||
}
|
||||
|
||||
/** Capabilities/roles an agent can have */
|
||||
export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "custom";
|
||||
|
||||
/** Agent record stored in the system */
|
||||
export interface Agent {
|
||||
/** Unique identifier (e.g., "agent-001") */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Role/capability of the agent */
|
||||
role: AgentCapability;
|
||||
/** Current lifecycle state */
|
||||
state: AgentState;
|
||||
/** ID of the task this agent is currently working on (if any) */
|
||||
taskId?: string;
|
||||
/** ISO-8601 timestamp when the agent was created */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
/** ISO-8601 timestamp of last successful heartbeat */
|
||||
lastHeartbeatAt?: string;
|
||||
/** Optional metadata */
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Extended agent information including heartbeat history */
|
||||
export interface AgentDetail extends Agent {
|
||||
/** Recent heartbeat events (last N events) */
|
||||
heartbeatHistory: AgentHeartbeatEvent[];
|
||||
/** Current active heartbeat run (if any) */
|
||||
activeRun?: AgentHeartbeatRun;
|
||||
/** All completed runs for this agent */
|
||||
completedRuns: AgentHeartbeatRun[];
|
||||
}
|
||||
|
||||
/** Input for creating a new agent */
|
||||
export interface AgentCreateInput {
|
||||
name: string;
|
||||
role: AgentCapability;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Input for updating an existing agent */
|
||||
export interface AgentUpdateInput {
|
||||
name?: string;
|
||||
role?: AgentCapability;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { NewTaskModal } from "./components/NewTaskModal";
|
||||
import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
|
||||
import { ActivityLogModal } from "./components/ActivityLogModal";
|
||||
import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||
import { AgentListModal } from "./components/AgentListModal";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
@@ -41,6 +42,7 @@ function AppInner() {
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [rootDir, setRootDir] = useState<string>(".");
|
||||
@@ -246,6 +248,10 @@ function AppInner() {
|
||||
const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||
const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||
|
||||
// Agent handlers
|
||||
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
|
||||
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
@@ -257,6 +263,7 @@ function AppInner() {
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenAgents={handleOpenAgents}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
filesOpen={filesOpen}
|
||||
@@ -407,6 +414,11 @@ function AppInner() {
|
||||
onClose={() => setWorkflowStepsOpen(false)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<AgentListModal
|
||||
isOpen={agentsOpen}
|
||||
onClose={handleCloseAgents}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ActivityEventType,
|
||||
WorkflowStep,
|
||||
WorkflowStepInput,
|
||||
WorkflowStepResult,
|
||||
} from "@kb/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@kb/core";
|
||||
@@ -458,6 +459,49 @@ export function fetchGitRemotes(): Promise<GitRemote[]> {
|
||||
return api<GitRemote[]>("/git/remotes");
|
||||
}
|
||||
|
||||
/** Detailed git remote info with fetch and push URLs */
|
||||
export interface GitRemoteDetailed {
|
||||
name: string;
|
||||
fetchUrl: string;
|
||||
pushUrl: string;
|
||||
}
|
||||
|
||||
/** Fetch all git remotes with their fetch and push URLs */
|
||||
export function fetchGitRemotesDetailed(): Promise<GitRemoteDetailed[]> {
|
||||
return api<GitRemoteDetailed[]>("/git/remotes/detailed");
|
||||
}
|
||||
|
||||
/** Add a new git remote */
|
||||
export function addGitRemote(name: string, url: string): Promise<void> {
|
||||
return api<void>("/git/remotes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, url }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a git remote */
|
||||
export function removeGitRemote(name: string): Promise<void> {
|
||||
return api<void>(`/git/remotes/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Rename a git remote */
|
||||
export function renameGitRemote(name: string, newName: string): Promise<void> {
|
||||
return api<void>(`/git/remotes/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ newName }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update the URL for a git remote */
|
||||
export function updateGitRemoteUrl(name: string, url: string): Promise<void> {
|
||||
return api<void>(`/git/remotes/${encodeURIComponent(name)}/url`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- PR Management API ---
|
||||
|
||||
/** PR info returned by PR endpoints */
|
||||
@@ -1229,6 +1273,11 @@ export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch workflow step results for a task */
|
||||
export function fetchWorkflowResults(taskId: string): Promise<WorkflowStepResult[]> {
|
||||
return api<WorkflowStepResult[]>(`/tasks/${encodeURIComponent(taskId)}/workflow-results`);
|
||||
}
|
||||
|
||||
// ── Workflow Step Templates ──────────────────────────────────────────────
|
||||
|
||||
/** Re-export WorkflowStepTemplate type from core */
|
||||
@@ -1425,3 +1474,67 @@ export function cancelSubtaskBreakdown(sessionId: string): Promise<void> {
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent API ────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentCreateInput, AgentUpdateInput } from "@kb/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentCreateInput, AgentUpdateInput };
|
||||
|
||||
/** Fetch all agents, optionally filtered by state or role */
|
||||
export function fetchAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.state) params.set("state", filter.state);
|
||||
if (filter?.role) params.set("role", filter.role);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<Agent[]>(`/agents${query}`);
|
||||
}
|
||||
|
||||
/** Fetch a single agent with heartbeat history */
|
||||
export function fetchAgent(agentId: string): Promise<AgentDetail> {
|
||||
return api<AgentDetail>(`/agents/${encodeURIComponent(agentId)}`);
|
||||
}
|
||||
|
||||
/** Create a new agent */
|
||||
export function createAgent(input: AgentCreateInput): Promise<Agent> {
|
||||
return api<Agent>("/agents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an agent */
|
||||
export function updateAgent(agentId: string, updates: AgentUpdateInput): Promise<Agent> {
|
||||
return api<Agent>(`/agents/${encodeURIComponent(agentId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an agent's state */
|
||||
export function updateAgentState(agentId: string, state: AgentState): Promise<Agent> {
|
||||
return api<Agent>(`/agents/${encodeURIComponent(agentId)}/state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete an agent */
|
||||
export function deleteAgent(agentId: string): Promise<void> {
|
||||
return api<void>(`/agents/${encodeURIComponent(agentId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a heartbeat for an agent */
|
||||
export function recordAgentHeartbeat(agentId: string, status: "ok" | "missed" | "recovered" = "ok"): Promise<AgentHeartbeatEvent> {
|
||||
return api<AgentHeartbeatEvent>(`/agents/${encodeURIComponent(agentId)}/heartbeat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch heartbeat history for an agent */
|
||||
export function fetchAgentHeartbeats(agentId: string, limit?: number): Promise<AgentHeartbeatEvent[]> {
|
||||
const query = limit !== undefined ? `?limit=${limit}` : "";
|
||||
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
|
||||
}
|
||||
|
||||
442
packages/dashboard/app/components/AgentListModal.tsx
Normal file
442
packages/dashboard/app/components/AgentListModal.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgentState, deleteAgent } from "../api";
|
||||
|
||||
interface AgentListModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
{ value: "triage", label: "Triage", icon: "🔍" },
|
||||
{ value: "executor", label: "Executor", icon: "⚡" },
|
||||
{ value: "reviewer", label: "Reviewer", icon: "👁" },
|
||||
{ value: "merger", label: "Merger", icon: "🔀" },
|
||||
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
|
||||
{ value: "custom", label: "Custom", icon: "🔧" },
|
||||
];
|
||||
|
||||
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
|
||||
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
|
||||
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newAgentName, setNewAgentName] = useState("");
|
||||
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filter = filterState !== "all" ? { state: filterState } : undefined;
|
||||
const data = await fetchAgents(filter);
|
||||
setAgents(data);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load agents: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filterState, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadAgents();
|
||||
}
|
||||
}, [isOpen, loadAgents]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole });
|
||||
addToast(`Agent "${newAgentName}" created`, "success");
|
||||
setNewAgentName("");
|
||||
setIsCreating(false);
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to create agent: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update state: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string, agentName: string) => {
|
||||
if (!confirm(`Delete agent "${agentName}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to delete agent: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
|
||||
|
||||
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = 60000; // 60 second timeout
|
||||
if (elapsed > timeoutMs) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal modal--wide">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
<Bot size={20} />
|
||||
Agents
|
||||
</h2>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => void loadAgents()}
|
||||
title="Refresh"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
|
||||
</button>
|
||||
<button className="btn-icon" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
{/* Filter and Create Bar */}
|
||||
<div className="agent-controls">
|
||||
<select
|
||||
className="select"
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{isCreating ? "Cancel" : "New Agent"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{isCreating && (
|
||||
<div className="agent-create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Agent name..."
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={newAgentRole}
|
||||
onChange={(e) => setNewAgentRole(e.target.value as AgentCapability)}
|
||||
>
|
||||
{AGENT_ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.icon} {role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn--primary" onClick={() => void handleCreate()}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent List */}
|
||||
<div className="agent-list">
|
||||
{agents.length === 0 ? (
|
||||
<div className="agent-empty">
|
||||
<Bot size={48} opacity={0.3} />
|
||||
<p>No agents found</p>
|
||||
<p className="text-secondary">Create an agent to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
agents.map(agent => {
|
||||
const health = getHealthStatus(agent);
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
return (
|
||||
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
|
||||
<div className="agent-card-header">
|
||||
<div className="agent-info">
|
||||
<span className="agent-icon">{getRoleIcon(agent.role)}</span>
|
||||
<div className="agent-meta">
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<span className="agent-id text-secondary">{agent.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-badges">
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
background: stateStyle.bg,
|
||||
color: stateStyle.text,
|
||||
border: `1px solid ${stateStyle.border}`,
|
||||
}}
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="badge" style={{ color: health.color }}>
|
||||
{health.icon} {health.label}
|
||||
</span>
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agent-card-body">
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">Working on:</span>
|
||||
<span className="badge">{agent.taskId}</span>
|
||||
</div>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
<div className="agent-heartbeat">
|
||||
<span className="text-secondary">Last heartbeat:</span>
|
||||
<span>{new Date(agent.lastHeartbeatAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="agent-card-actions">
|
||||
{agent.state === "idle" && (
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} /> Pause
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Resume"
|
||||
>
|
||||
<Play size={14} /> Resume
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.modal--wide {
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agent-controls .select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.agent-create-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.agent-create-form .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.agent-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.agent-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.agent-id {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-task,
|
||||
.agent-heartbeat {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
GitPushResult,
|
||||
GitStash,
|
||||
GitFileChange,
|
||||
GitRemoteDetailed,
|
||||
} from "../api";
|
||||
import {
|
||||
fetchGitStatus,
|
||||
@@ -34,6 +35,11 @@ import {
|
||||
createCommit,
|
||||
discardChanges,
|
||||
fetchUnstagedDiff,
|
||||
fetchGitRemotesDetailed,
|
||||
addGitRemote,
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
} from "../api";
|
||||
import {
|
||||
GitBranch as GitBranchIcon,
|
||||
@@ -752,6 +758,7 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
|
||||
onFetch={handleFetch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1463,7 +1470,7 @@ function StashesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/** Remotes panel with fetch/pull/push */
|
||||
/** Enhanced Remotes panel with full remote management capabilities */
|
||||
function RemotesPanel({
|
||||
status,
|
||||
remoteLoading,
|
||||
@@ -1471,6 +1478,7 @@ function RemotesPanel({
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
addToast,
|
||||
}: {
|
||||
status: GitStatus | null;
|
||||
remoteLoading: string | null;
|
||||
@@ -1478,67 +1486,352 @@ function RemotesPanel({
|
||||
onFetch: () => void;
|
||||
onPull: () => void;
|
||||
onPush: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}) {
|
||||
const [remotes, setRemotes] = useState<GitRemoteDetailed[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [remoteActionLoading, setRemoteActionLoading] = useState<string | null>(null);
|
||||
const [newRemoteName, setNewRemoteName] = useState("");
|
||||
const [newRemoteUrl, setNewRemoteUrl] = useState("");
|
||||
const [editingRemote, setEditingRemote] = useState<string | null>(null);
|
||||
const [editUrlValue, setEditUrlValue] = useState("");
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
// Fetch remotes when panel mounts
|
||||
useEffect(() => {
|
||||
loadRemotes();
|
||||
}, []);
|
||||
|
||||
const loadRemotes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchGitRemotesDetailed();
|
||||
setRemotes(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load remotes", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRemote = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newRemoteName.trim() || !newRemoteUrl.trim()) return;
|
||||
|
||||
setRemoteActionLoading("add");
|
||||
try {
|
||||
await addGitRemote(newRemoteName.trim(), newRemoteUrl.trim());
|
||||
addToast(`Remote '${newRemoteName}' added successfully`, "success");
|
||||
setNewRemoteName("");
|
||||
setNewRemoteUrl("");
|
||||
setShowAddForm(false);
|
||||
await loadRemotes();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to add remote", "error");
|
||||
} finally {
|
||||
setRemoteActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRemote = async (name: string) => {
|
||||
if (!confirm(`Are you sure you want to remove remote '${name}'?`)) return;
|
||||
|
||||
setRemoteActionLoading(`remove-${name}`);
|
||||
try {
|
||||
await removeGitRemote(name);
|
||||
addToast(`Remote '${name}' removed`, "success");
|
||||
await loadRemotes();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to remove remote", "error");
|
||||
} finally {
|
||||
setRemoteActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameRemote = async (oldName: string) => {
|
||||
if (!editNameValue.trim()) return;
|
||||
|
||||
setRemoteActionLoading(`rename-${oldName}`);
|
||||
try {
|
||||
await renameGitRemote(oldName, editNameValue.trim());
|
||||
addToast(`Remote renamed to '${editNameValue.trim()}'`, "success");
|
||||
setEditingRemote(null);
|
||||
setEditNameValue("");
|
||||
await loadRemotes();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to rename remote", "error");
|
||||
} finally {
|
||||
setRemoteActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUrl = async (name: string) => {
|
||||
if (!editUrlValue.trim()) return;
|
||||
|
||||
setRemoteActionLoading(`url-${name}`);
|
||||
try {
|
||||
await updateGitRemoteUrl(name, editUrlValue.trim());
|
||||
addToast(`Remote URL updated`, "success");
|
||||
setEditingRemote(null);
|
||||
setEditUrlValue("");
|
||||
await loadRemotes();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to update remote URL", "error");
|
||||
} finally {
|
||||
setRemoteActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingUrl = (remote: GitRemoteDetailed) => {
|
||||
setEditingRemote(`url-${remote.name}`);
|
||||
setEditUrlValue(remote.pushUrl || remote.fetchUrl);
|
||||
};
|
||||
|
||||
const startEditingName = (remote: GitRemoteDetailed) => {
|
||||
setEditingRemote(`name-${remote.name}`);
|
||||
setEditNameValue(remote.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gm-panel" data-testid="remotes-panel">
|
||||
<div className="gm-panel gm-remotes-panel" data-testid="remotes-panel">
|
||||
<div className="gm-panel-header">
|
||||
<h4>Remote Operations</h4>
|
||||
<h4>Remote Management</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
>
|
||||
{showAddForm ? <X size={14} /> : <Plus size={14} />}
|
||||
{showAddForm ? "Cancel" : "Add Remote"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="gm-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
<div className="gm-remote-indicator ahead">
|
||||
<ArrowUp size={16} />
|
||||
{status.ahead} commit(s) to push
|
||||
</div>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<div className="gm-remote-indicator behind">
|
||||
<ArrowDown size={16} />
|
||||
{status.behind} commit(s) to pull
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Add Remote Form */}
|
||||
{showAddForm && (
|
||||
<form className="gm-remote-form" onSubmit={handleAddRemote}>
|
||||
<div className="gm-form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Remote name (e.g., origin)"
|
||||
value={newRemoteName}
|
||||
onChange={(e) => setNewRemoteName(e.target.value)}
|
||||
disabled={remoteActionLoading === "add"}
|
||||
className="gm-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Repository URL"
|
||||
value={newRemoteUrl}
|
||||
onChange={(e) => setNewRemoteUrl(e.target.value)}
|
||||
disabled={remoteActionLoading === "add"}
|
||||
className="gm-input gm-input-url"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || remoteActionLoading === "add"}
|
||||
>
|
||||
{remoteActionLoading === "add" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<Plus size={14} />
|
||||
)}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="gm-remote-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onFetch}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "fetch" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Fetch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPull}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "pull" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitPullRequest size={14} />
|
||||
)}
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPush}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "push" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
{/* Remote Operations (Fetch/Pull/Push) */}
|
||||
<div className="gm-remote-operations">
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="gm-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
<div className="gm-remote-indicator ahead">
|
||||
<ArrowUp size={16} />
|
||||
{status.ahead} commit(s) to push
|
||||
</div>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<div className="gm-remote-indicator behind">
|
||||
<ArrowDown size={16} />
|
||||
{status.behind} commit(s) to pull
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gm-remote-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onFetch}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "fetch" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Fetch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPull}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "pull" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitPullRequest size={14} />
|
||||
)}
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPush}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "push" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remotes List */}
|
||||
<div className="gm-remote-list">
|
||||
{loading ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
Loading remotes...
|
||||
</div>
|
||||
) : remotes.length === 0 ? (
|
||||
<div className="gm-empty">No remotes configured</div>
|
||||
) : (
|
||||
remotes.map((remote) => (
|
||||
<div key={remote.name} className="gm-remote-item">
|
||||
<div className="gm-remote-info">
|
||||
{editingRemote === `name-${remote.name}` ? (
|
||||
<div className="gm-remote-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
className="gm-input"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleRenameRemote(remote.name)}
|
||||
disabled={remoteActionLoading === `rename-${remote.name}`}
|
||||
>
|
||||
{remoteActionLoading === `rename-${remote.name}` ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Check size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingRemote(null);
|
||||
setEditNameValue("");
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-name-row">
|
||||
<span className="gm-remote-name">{remote.name}</span>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => startEditingName(remote)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Rename remote"
|
||||
>
|
||||
<GitBranchIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gm-remote-urls">
|
||||
<div className="gm-remote-url">
|
||||
<span className="gm-url-label">Fetch:</span>
|
||||
<span className="gm-url-value" title={remote.fetchUrl}>
|
||||
{remote.fetchUrl}
|
||||
</span>
|
||||
</div>
|
||||
{editingRemote === `url-${remote.name}` ? (
|
||||
<div className="gm-remote-edit gm-url-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editUrlValue}
|
||||
onChange={(e) => setEditUrlValue(e.target.value)}
|
||||
className="gm-input"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleUpdateUrl(remote.name)}
|
||||
disabled={remoteActionLoading === `url-${remote.name}`}
|
||||
>
|
||||
{remoteActionLoading === `url-${remote.name}` ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Check size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingRemote(null);
|
||||
setEditUrlValue("");
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-url gm-push-url">
|
||||
<span className="gm-url-label">Push:</span>
|
||||
<span className="gm-url-value" title={remote.pushUrl}>
|
||||
{remote.pushUrl || remote.fetchUrl}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => startEditingUrl(remote)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit URL"
|
||||
>
|
||||
<FileEdit size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-remote-actions-inline">
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRemoveRemote(remote.name)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Remove remote"
|
||||
>
|
||||
{remoteActionLoading === `remove-${remote.name}` ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastRemoteResult && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot } from "lucide-react";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -25,6 +25,7 @@ interface HeaderProps {
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onOpenAgents?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
@@ -65,6 +66,7 @@ export function Header({
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
onOpenAgents,
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
@@ -365,6 +367,18 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Agents - desktop only */}
|
||||
{!isMobile && onOpenAgents && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenAgents}
|
||||
title="Manage Agents"
|
||||
data-testid="agents-btn"
|
||||
>
|
||||
<Bot size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings - always inline on desktop */}
|
||||
{!isMobile && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
@@ -451,6 +465,17 @@ export function Header({
|
||||
<span>Workflow Steps</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenAgents && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenAgents)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-agents-btn"
|
||||
>
|
||||
<Bot size={16} />
|
||||
<span>Manage Agents</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSettings)}
|
||||
|
||||
138
packages/dashboard/app/components/WorkflowResultsTab.test.tsx
Normal file
138
packages/dashboard/app/components/WorkflowResultsTab.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||
import type { WorkflowStepResult } from "@kb/core";
|
||||
|
||||
describe("WorkflowResultsTab", () => {
|
||||
const mockResults: WorkflowStepResult[] = [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "QA Check",
|
||||
status: "passed",
|
||||
output: "All tests passed successfully.",
|
||||
startedAt: "2026-03-31T10:00:00Z",
|
||||
completedAt: "2026-03-31T10:02:30Z",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Security Audit",
|
||||
status: "failed",
|
||||
output: "Found 2 security issues in auth.ts",
|
||||
startedAt: "2026-03-31T10:02:35Z",
|
||||
completedAt: "2026-03-31T10:03:15Z",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-003",
|
||||
workflowStepName: "Documentation Review",
|
||||
status: "skipped",
|
||||
output: undefined,
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-004",
|
||||
workflowStepName: "Performance Check",
|
||||
status: "pending",
|
||||
output: undefined,
|
||||
startedAt: "2026-03-31T10:03:20Z",
|
||||
completedAt: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it("renders list of workflow step results", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={mockResults} />);
|
||||
|
||||
expect(screen.getByTestId("workflow-results-list")).toBeInTheDocument();
|
||||
expect(screen.getByText("QA Check")).toBeInTheDocument();
|
||||
expect(screen.getByText("Security Audit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Performance Check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders correct status badges for each result", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={mockResults} />);
|
||||
|
||||
// Passed badge
|
||||
const passedBadge = screen.getByTestId("workflow-result-badge-WS-001");
|
||||
expect(passedBadge).toHaveTextContent("Passed");
|
||||
expect(passedBadge).toHaveStyle({ backgroundColor: "var(--color-success, #3fb950)" });
|
||||
|
||||
// Failed badge
|
||||
const failedBadge = screen.getByTestId("workflow-result-badge-WS-002");
|
||||
expect(failedBadge).toHaveTextContent("Failed");
|
||||
expect(failedBadge).toHaveStyle({ backgroundColor: "var(--color-error, #f85149)" });
|
||||
|
||||
// Skipped badge
|
||||
const skippedBadge = screen.getByTestId("workflow-result-badge-WS-003");
|
||||
expect(skippedBadge).toHaveTextContent("Skipped");
|
||||
|
||||
// Pending badge
|
||||
const pendingBadge = screen.getByTestId("workflow-result-badge-WS-004");
|
||||
expect(pendingBadge).toHaveTextContent("Running…");
|
||||
expect(pendingBadge).toHaveStyle({ backgroundColor: "var(--todo, #58a6ff)" });
|
||||
});
|
||||
|
||||
it("shows output content for each result", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={mockResults} />);
|
||||
|
||||
expect(screen.getByTestId("workflow-result-output-WS-001")).toHaveTextContent(
|
||||
"All tests passed successfully."
|
||||
);
|
||||
expect(screen.getByTestId("workflow-result-output-WS-002")).toHaveTextContent(
|
||||
"Found 2 security issues in auth.ts"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles results without output gracefully", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={mockResults} />);
|
||||
|
||||
// WS-003 and WS-004 have no output, so output elements should not be rendered
|
||||
expect(screen.queryByTestId("workflow-result-output-WS-003")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflow-result-output-WS-004")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no results", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={[]} />);
|
||||
|
||||
expect(screen.getByTestId("workflow-results-empty")).toBeInTheDocument();
|
||||
expect(screen.getByText("No workflow steps have run yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state when loading prop is true", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={[]} loading={true} />);
|
||||
|
||||
expect(screen.getByTestId("workflow-results-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading workflow results…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays execution timestamps when available", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={mockResults} />);
|
||||
|
||||
// Check that timestamps are displayed for results that have them
|
||||
const timestamps = screen.getAllByText(/Started:/);
|
||||
expect(timestamps.length).toBeGreaterThanOrEqual(3); // 3 results have startedAt
|
||||
});
|
||||
|
||||
it("displays duration when start and end times are available", () => {
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={mockResults} />);
|
||||
|
||||
// The first result has a 2m 30s duration
|
||||
expect(screen.getByText("2m 30s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles results with missing timestamps gracefully", () => {
|
||||
const resultsWithoutTimestamps: WorkflowStepResult[] = [
|
||||
{
|
||||
workflowStepId: "WS-005",
|
||||
workflowStepName: "Simple Check",
|
||||
status: "passed",
|
||||
output: "Done",
|
||||
},
|
||||
];
|
||||
|
||||
render(<WorkflowResultsTab taskId="KB-001" results={resultsWithoutTimestamps} />);
|
||||
|
||||
expect(screen.getByText("Simple Check")).toBeInTheDocument();
|
||||
// Should not crash without timestamps
|
||||
});
|
||||
});
|
||||
129
packages/dashboard/app/components/WorkflowResultsTab.tsx
Normal file
129
packages/dashboard/app/components/WorkflowResultsTab.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { WorkflowStepResult } from "@kb/core";
|
||||
|
||||
interface WorkflowResultsTabProps {
|
||||
taskId: string;
|
||||
results: WorkflowStepResult[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function getStatusColor(status: WorkflowStepResult["status"]): string {
|
||||
switch (status) {
|
||||
case "passed":
|
||||
return "var(--color-success, #3fb950)";
|
||||
case "failed":
|
||||
return "var(--color-error, #f85149)";
|
||||
case "skipped":
|
||||
return "var(--text-dim, #484f58)";
|
||||
case "pending":
|
||||
return "var(--todo, #58a6ff)";
|
||||
default:
|
||||
return "var(--text-dim, #484f58)";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: WorkflowStepResult["status"]): string {
|
||||
switch (status) {
|
||||
case "passed":
|
||||
return "Passed";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
case "skipped":
|
||||
return "Skipped";
|
||||
case "pending":
|
||||
return "Running…";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(startedAt?: string, completedAt?: string): string | null {
|
||||
if (!startedAt || !completedAt) return null;
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = new Date(completedAt).getTime();
|
||||
const durationMs = end - start;
|
||||
if (durationMs < 1000) return `${durationMs}ms`;
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso?: string): string | null {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function WorkflowResultsTab({ taskId, results, loading }: WorkflowResultsTabProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="workflow-results-loading" data-testid="workflow-results-loading">
|
||||
<div className="workflow-results-spinner" />
|
||||
<span>Loading workflow results…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="workflow-results-empty" data-testid="workflow-results-empty">
|
||||
<p>No workflow steps have run yet.</p>
|
||||
<p className="workflow-results-empty-hint">
|
||||
Workflow steps will execute after the main task implementation completes.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-results-list" data-testid="workflow-results-list">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={`${result.workflowStepId}-${index}`}
|
||||
className={`workflow-result-item workflow-result-item--${result.status}`}
|
||||
data-testid={`workflow-result-item-${result.workflowStepId}`}
|
||||
>
|
||||
<div className="workflow-result-header">
|
||||
<div className="workflow-result-name">{result.workflowStepName}</div>
|
||||
<span
|
||||
className={`workflow-result-badge workflow-result-badge--${result.status}`}
|
||||
style={{
|
||||
backgroundColor: getStatusColor(result.status),
|
||||
color: result.status === "skipped" ? "var(--text-muted)" : "#fff",
|
||||
}}
|
||||
data-testid={`workflow-result-badge-${result.workflowStepId}`}
|
||||
>
|
||||
{getStatusLabel(result.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="workflow-result-meta">
|
||||
{result.startedAt && (
|
||||
<span className="workflow-result-timestamp">
|
||||
Started: {formatTimestamp(result.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
{result.completedAt && (
|
||||
<span className="workflow-result-duration">
|
||||
{formatDuration(result.startedAt, result.completedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.output && (
|
||||
<div className="workflow-result-output-section">
|
||||
<div className="workflow-result-output-label">Output:</div>
|
||||
<pre
|
||||
className="workflow-result-output"
|
||||
data-testid={`workflow-result-output-${result.workflowStepId}`}
|
||||
>
|
||||
{result.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,11 @@ vi.mock("../../api", async () => {
|
||||
unstageFiles: vi.fn(),
|
||||
createCommit: vi.fn(),
|
||||
discardChanges: vi.fn(),
|
||||
fetchGitRemotesDetailed: vi.fn(),
|
||||
addGitRemote: vi.fn(),
|
||||
removeGitRemote: vi.fn(),
|
||||
renameGitRemote: vi.fn(),
|
||||
updateGitRemoteUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,6 +58,11 @@ import {
|
||||
unstageFiles,
|
||||
createCommit,
|
||||
discardChanges,
|
||||
fetchGitRemotesDetailed,
|
||||
addGitRemote,
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
} from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -152,6 +162,13 @@ describe("GitManagerModal", () => {
|
||||
(fetchRemote as any).mockResolvedValue({ fetched: true, message: "Fetched" });
|
||||
(pullBranch as any).mockResolvedValue({ success: true, message: "Already up to date." });
|
||||
(pushBranch as any).mockResolvedValue({ success: true, message: "Push completed" });
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
|
||||
]);
|
||||
(addGitRemote as any).mockResolvedValue(undefined);
|
||||
(removeGitRemote as any).mockResolvedValue(undefined);
|
||||
(renameGitRemote as any).mockResolvedValue(undefined);
|
||||
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Basic Rendering ─────────────────────────────────────────
|
||||
@@ -832,6 +849,202 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remote Management Tests ───────────────────────────────────
|
||||
|
||||
it("shows list of remotes with URLs", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
|
||||
{ name: "upstream", fetchUrl: "https://github.com/upstream/kb.git", pushUrl: "git@github.com:upstream/kb.git" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByText("upstream")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching remotes", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading remotes...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a new remote successfully", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
|
||||
(addGitRemote as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add Remote")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Add Remote"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Remote name (e.g., origin)");
|
||||
const urlInput = screen.getByPlaceholderText("Repository URL");
|
||||
|
||||
await user.type(nameInput, "origin");
|
||||
await user.type(urlInput, "https://github.com/test/repo.git");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^add$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addGitRemote).toHaveBeenCalledWith("origin", "https://github.com/test/repo.git");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'origin' added successfully", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when adding remote fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
|
||||
(addGitRemote as any).mockRejectedValue(new Error("Remote 'origin' already exists"));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add Remote")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Add Remote"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Remote name (e.g., origin)");
|
||||
const urlInput = screen.getByPlaceholderText("Repository URL");
|
||||
|
||||
await user.type(nameInput, "origin");
|
||||
await user.type(urlInput, "https://github.com/test/repo.git");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^add$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'origin' already exists", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a remote with confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
|
||||
]);
|
||||
(removeGitRemote as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const removeButton = screen.getByTitle("Remove remote");
|
||||
await user.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.confirm).toHaveBeenCalledWith("Are you sure you want to remove remote 'origin'?");
|
||||
expect(removeGitRemote).toHaveBeenCalledWith("origin");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'origin' removed", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("renames a remote", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
|
||||
]);
|
||||
(renameGitRemote as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const renameButton = screen.getByTitle("Rename remote");
|
||||
await user.click(renameButton);
|
||||
|
||||
// The input should appear - find it by its autoFocus or by looking for an input
|
||||
const nameInput = screen.getByDisplayValue("origin");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "upstream");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameGitRemote).toHaveBeenCalledWith("origin", "upstream");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote renamed to 'upstream'", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates remote URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://old-url.com/repo.git", pushUrl: "https://old-url.com/repo.git" },
|
||||
]);
|
||||
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByTitle("Edit URL");
|
||||
await user.click(editButton);
|
||||
|
||||
const urlInput = screen.getByDisplayValue("https://old-url.com/repo.git");
|
||||
await user.clear(urlInput);
|
||||
await user.type(urlInput, "https://new-url.com/repo.git");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateGitRemoteUrl).toHaveBeenCalledWith("origin", "https://new-url.com/repo.git");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote URL updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockRejectedValue(new Error("Failed to load remotes"));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Failed to load remotes", "error");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error States ───────────────────────────────────────────
|
||||
|
||||
it("shows error state when data fetch fails", async () => {
|
||||
|
||||
@@ -10548,6 +10548,152 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Remote Management (enhanced) ── */
|
||||
|
||||
.gm-remotes-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.gm-remote-form {
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.gm-form-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gm-form-row .gm-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.gm-form-row .gm-input-url {
|
||||
flex: 2;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.gm-remote-operations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.gm-remote-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gm-remote-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.gm-remote-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.gm-remote-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gm-remote-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gm-remote-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gm-remote-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gm-url-label {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.gm-url-value {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-url.gm-push-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-url.gm-push-url .gm-url-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-edit.gm-url-edit {
|
||||
margin-left: 44px;
|
||||
}
|
||||
|
||||
.gm-remote-edit .gm-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gm-remote-actions-inline {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gm-remote-actions-inline .btn {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -10622,6 +10768,34 @@ html .column.drag-over * {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gm-remote-form .gm-form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gm-remote-form .gm-form-row .gm-input,
|
||||
.gm-remote-form .gm-form-row .gm-input-url {
|
||||
flex: 1 1 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.gm-remote-item {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-actions-inline {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.gm-remote-edit {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gm-remote-edit.gm-url-edit {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.gm-commit-form .gm-commit-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -631,6 +631,160 @@ function pushGitBranch(): GitPushResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git Remote Management Helper Functions ───────────────────────────────
|
||||
|
||||
/** Detailed git remote info with fetch and push URLs */
|
||||
export interface GitRemoteDetailed {
|
||||
name: string;
|
||||
fetchUrl: string;
|
||||
pushUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a git URL format.
|
||||
* Accepts: https://, git@, file://, or ssh:// formats
|
||||
* Rejects URLs containing shell metacharacters to prevent command injection.
|
||||
*/
|
||||
function isValidGitUrl(url: string): boolean {
|
||||
if (!url || typeof url !== "string") return false;
|
||||
// Reject URLs with shell metacharacters to prevent injection
|
||||
if (/[;<>&|`$(){}[\]\r\n]/.test(url)) return false;
|
||||
// Reject URLs starting with dash (could be interpreted as option)
|
||||
if (url.startsWith("-")) return false;
|
||||
// HTTPS URL: https://host.com/path.git or https://host.com/path
|
||||
if (/^https?:\/\/.+/.test(url)) return true;
|
||||
// SSH URL: git@host.com:path.git or git@host.com:path
|
||||
if (/^git@[^:]+:.+/.test(url)) return true;
|
||||
// File URL: file:///path/to/repo
|
||||
if (/^file:\/\/.+/.test(url)) return true;
|
||||
// SSH URL with protocol: ssh://git@host.com/path.git
|
||||
if (/^ssh:\/\/.+/.test(url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all git remotes with their fetch and push URLs.
|
||||
* Executes `git remote -v` and parses the output.
|
||||
*/
|
||||
function listGitRemotes(): GitRemoteDetailed[] {
|
||||
try {
|
||||
const output = execSync("git remote -v", { encoding: "utf-8", timeout: 5000 });
|
||||
|
||||
const remotes = new Map<string, { fetchUrl: string; pushUrl: string }>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
// Parse lines like: "origin https://github.com/owner/repo.git (fetch)"
|
||||
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, name, url, type] = match;
|
||||
|
||||
if (!remotes.has(name)) {
|
||||
remotes.set(name, { fetchUrl: "", pushUrl: "" });
|
||||
}
|
||||
|
||||
const remote = remotes.get(name)!;
|
||||
if (type === "fetch") {
|
||||
remote.fetchUrl = url;
|
||||
} else {
|
||||
remote.pushUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(remotes.entries()).map(([name, urls]) => ({
|
||||
name,
|
||||
fetchUrl: urls.fetchUrl,
|
||||
pushUrl: urls.pushUrl,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new git remote.
|
||||
*/
|
||||
function addGitRemote(name: string, url: string): void {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
if (!isValidGitUrl(url)) {
|
||||
throw new Error("Invalid git URL format");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote add ${name} ${url}`, { encoding: "utf-8", timeout: 10000 });
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("already exists")) {
|
||||
throw new Error(`Remote '${name}' already exists`);
|
||||
}
|
||||
throw new Error(message || "Failed to add remote");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a git remote.
|
||||
*/
|
||||
function removeGitRemote(name: string): void {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote remove ${name}`, { encoding: "utf-8", timeout: 10000 });
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("No such remote")) {
|
||||
throw new Error(`Remote '${name}' does not exist`);
|
||||
}
|
||||
throw new Error(message || "Failed to remove remote");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a git remote.
|
||||
*/
|
||||
function renameGitRemote(oldName: string, newName: string): void {
|
||||
if (!isValidBranchName(oldName)) {
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
if (!isValidBranchName(newName)) {
|
||||
throw new Error("Invalid new remote name");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote rename ${oldName} ${newName}`, { encoding: "utf-8", timeout: 10000 });
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("No such remote")) {
|
||||
throw new Error(`Remote '${oldName}' does not exist`);
|
||||
}
|
||||
if (message.includes("already exists")) {
|
||||
throw new Error(`Remote '${newName}' already exists`);
|
||||
}
|
||||
throw new Error(message || "Failed to rename remote");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the URL for a git remote.
|
||||
*/
|
||||
function setGitRemoteUrl(name: string, url: string): void {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
if (!isValidGitUrl(url)) {
|
||||
throw new Error("Invalid git URL format");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote set-url ${name} ${url}`, { encoding: "utf-8", timeout: 10000 });
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("No such remote")) {
|
||||
throw new Error(`Remote '${name}' does not exist`);
|
||||
}
|
||||
throw new Error(message || "Failed to update remote URL");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git Stash, Stage, Commit Helper Functions ────────────────────────────
|
||||
|
||||
/** Git stash entry */
|
||||
@@ -1367,6 +1521,24 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id/workflow-results
|
||||
* Get workflow step execution results for a task.
|
||||
* Returns: WorkflowStepResult[]
|
||||
*/
|
||||
router.get("/tasks/:id/workflow-results", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
res.json(task.workflowStepResults || []);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
@@ -1592,6 +1764,145 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/remotes/detailed
|
||||
* Returns all git remotes with their fetch and push URLs.
|
||||
* Response: Array of GitRemoteDetailed objects [{ name: string, fetchUrl: string, pushUrl: string }]
|
||||
*/
|
||||
router.get("/git/remotes/detailed", (_req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const remotes = listGitRemotes();
|
||||
res.json(remotes);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/remotes
|
||||
* Add a new git remote.
|
||||
* Body: { name: string, url: string }
|
||||
*/
|
||||
router.post("/git/remotes", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name, url } = req.body;
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
}
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "url is required" });
|
||||
return;
|
||||
}
|
||||
addGitRemote(name, url);
|
||||
res.status(201).json({ name, added: true });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("Invalid remote name")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message?.includes("Invalid git URL")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message?.includes("already exists")) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/git/remotes/:name
|
||||
* Remove a git remote.
|
||||
*/
|
||||
router.delete("/git/remotes/:name", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name } = req.params;
|
||||
removeGitRemote(name);
|
||||
res.json({ name, removed: true });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("Invalid remote name")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message?.includes("does not exist")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/git/remotes/:name
|
||||
* Rename a git remote.
|
||||
* Body: { newName: string }
|
||||
*/
|
||||
router.patch("/git/remotes/:name", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name } = req.params;
|
||||
const { newName } = req.body;
|
||||
if (!newName || typeof newName !== "string") {
|
||||
res.status(400).json({ error: "newName is required" });
|
||||
return;
|
||||
}
|
||||
renameGitRemote(name, newName);
|
||||
res.json({ oldName: name, newName, renamed: true });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("Invalid")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message?.includes("does not exist")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("already exists")) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/git/remotes/:name/url
|
||||
* Update the URL for a git remote.
|
||||
* Body: { url: string }
|
||||
*/
|
||||
router.put("/git/remotes/:name/url", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "url is required" });
|
||||
return;
|
||||
}
|
||||
setGitRemoteUrl(name, url);
|
||||
res.json({ name, url, updated: true });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("Invalid")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message?.includes("does not exist")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/status
|
||||
* Returns current git status: branch, commit hash, dirty state, ahead/behind counts.
|
||||
@@ -4766,6 +5077,200 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Routes ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/agents
|
||||
* List all agents with optional filtering.
|
||||
* Query params: state, role
|
||||
*/
|
||||
router.get("/agents", async (req, res) => {
|
||||
try {
|
||||
const filter: { state?: string; role?: string } = {};
|
||||
if (req.query.state && typeof req.query.state === "string") {
|
||||
filter.state = req.query.state;
|
||||
}
|
||||
if (req.query.role && typeof req.query.role === "string") {
|
||||
filter.role = req.query.role;
|
||||
}
|
||||
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: import("@kb/core").AgentCapability });
|
||||
res.json(agents);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents
|
||||
* Create a new agent.
|
||||
* Body: { name: string, role: string, metadata?: object }
|
||||
*/
|
||||
router.post("/agents", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata } = req.body;
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
}
|
||||
if (!role || typeof role !== "string") {
|
||||
res.status(400).json({ error: "role is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.createAgent({ name, role: role as import("@kb/core").AgentCapability, metadata });
|
||||
res.status(201).json(agent);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id
|
||||
* Get agent by ID with heartbeat history.
|
||||
*/
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.getAgentDetail(req.params.id, 50);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/agents/:id
|
||||
* Update agent fields.
|
||||
*/
|
||||
router.patch("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata } = req.body;
|
||||
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, { name, role, metadata });
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/state
|
||||
* Update agent state.
|
||||
* Body: { state: AgentState }
|
||||
*/
|
||||
router.post("/agents/:id/state", async (req, res) => {
|
||||
try {
|
||||
const { state } = req.body;
|
||||
if (!state || typeof state !== "string") {
|
||||
res.status(400).json({ error: "state is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgentState(req.params.id, state as import("@kb/core").AgentState);
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("Invalid state transition") || err.message?.includes("Cannot transition from terminated")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id
|
||||
* Delete an agent.
|
||||
*/
|
||||
router.delete("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
await agentStore.deleteAgent(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/heartbeat
|
||||
* Record a heartbeat for an agent.
|
||||
*/
|
||||
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||
try {
|
||||
const { status = "ok" } = req.body;
|
||||
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const event = await agentStore.recordHeartbeat(req.params.id, status as "ok" | "missed" | "recovered");
|
||||
res.json(event);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/heartbeats
|
||||
* Get heartbeat history for an agent.
|
||||
* Query: limit (default: 50)
|
||||
*/
|
||||
router.get("/agents/:id/heartbeats", async (req, res) => {
|
||||
try {
|
||||
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
||||
|
||||
const { AgentStore } = await import("@kb/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getRootDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const history = await agentStore.getHeartbeatHistory(req.params.id, limit);
|
||||
res.json(history);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
241
packages/engine/src/agent-heartbeat.ts
Normal file
241
packages/engine/src/agent-heartbeat.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* HeartbeatMonitor - Runtime monitoring for agent health
|
||||
*
|
||||
* Monitors agents via periodic polling and detects missed heartbeats.
|
||||
* Follows the StuckTaskDetector pattern for consistency.
|
||||
*
|
||||
* Callback pattern (not EventEmitter):
|
||||
* - onMissed: Called when an agent misses its heartbeat
|
||||
* - onRecovered: Called when an agent recovers after a missed heartbeat
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, Agent, AgentState } from "@kb/core";
|
||||
|
||||
/** Options for HeartbeatMonitor constructor */
|
||||
export interface HeartbeatMonitorOptions {
|
||||
/** AgentStore instance for persistence */
|
||||
store: AgentStore;
|
||||
/** Polling interval in milliseconds (default: 30000) */
|
||||
pollIntervalMs?: number;
|
||||
/** Heartbeat timeout in milliseconds (default: 60000) */
|
||||
heartbeatTimeoutMs?: number;
|
||||
/** Callback when an agent misses its heartbeat */
|
||||
onMissed?: (agentId: string) => void;
|
||||
/** Callback when an agent recovers after a missed heartbeat */
|
||||
onRecovered?: (agentId: string) => void;
|
||||
/** Callback when an unresponsive agent is terminated */
|
||||
onTerminated?: (agentId: string) => void;
|
||||
}
|
||||
|
||||
/** Session interface for disposing agent resources */
|
||||
export interface AgentSession {
|
||||
/** Dispose the agent session (stop execution, cleanup resources) */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** In-memory tracking data for a monitored agent */
|
||||
interface TrackedAgent {
|
||||
agentId: string;
|
||||
session: AgentSession;
|
||||
runId: string;
|
||||
lastSeen: number; // timestamp from Date.now()
|
||||
missedHeartbeatReported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HeartbeatMonitor monitors agents via periodic polling.
|
||||
* Detects missed heartbeats and auto-terminates unresponsive agents.
|
||||
*/
|
||||
export class HeartbeatMonitor {
|
||||
private store: AgentStore;
|
||||
private pollIntervalMs: number;
|
||||
private heartbeatTimeoutMs: number;
|
||||
private onMissed?: (agentId: string) => void;
|
||||
private onRecovered?: (agentId: string) => void;
|
||||
private onTerminated?: (agentId: string) => void;
|
||||
|
||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||
private pollInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
|
||||
constructor(options: HeartbeatMonitorOptions) {
|
||||
this.store = options.store;
|
||||
this.pollIntervalMs = options.pollIntervalMs ?? 30000;
|
||||
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 60000;
|
||||
this.onMissed = options.onMissed;
|
||||
this.onRecovered = options.onRecovered;
|
||||
this.onTerminated = options.onTerminated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the heartbeat monitoring loop.
|
||||
* Safe to call multiple times - no-op if already running.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
this.pollInterval = setInterval(() => {
|
||||
void this.checkMissedHeartbeats();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the heartbeat monitoring loop.
|
||||
* Does not untrack agents - they remain in memory.
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
this.isRunning = false;
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the monitor is currently running.
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an agent for monitoring.
|
||||
* @param agentId - The agent ID
|
||||
* @param session - Session with dispose() for cleanup
|
||||
* @param runId - The heartbeat run ID
|
||||
*/
|
||||
trackAgent(agentId: string, session: AgentSession, runId: string): void {
|
||||
const tracked: TrackedAgent = {
|
||||
agentId,
|
||||
session,
|
||||
runId,
|
||||
lastSeen: Date.now(),
|
||||
missedHeartbeatReported: false,
|
||||
};
|
||||
|
||||
this.trackedAgents.set(agentId, tracked);
|
||||
|
||||
// Record initial heartbeat
|
||||
void this.store.recordHeartbeat(agentId, "ok", runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an agent from monitoring.
|
||||
* Does NOT end the heartbeat run - caller's responsibility.
|
||||
* @param agentId - The agent ID
|
||||
*/
|
||||
untrackAgent(agentId: string): void {
|
||||
this.trackedAgents.delete(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a heartbeat for a tracked agent.
|
||||
* @param agentId - The agent ID
|
||||
*/
|
||||
recordHeartbeat(agentId: string): void {
|
||||
const tracked = this.trackedAgents.get(agentId);
|
||||
if (!tracked) return;
|
||||
|
||||
tracked.lastSeen = Date.now();
|
||||
|
||||
// If recovering from a missed heartbeat
|
||||
if (tracked.missedHeartbeatReported) {
|
||||
tracked.missedHeartbeatReported = false;
|
||||
void this.store.recordHeartbeat(agentId, "recovered", tracked.runId);
|
||||
this.onRecovered?.(agentId);
|
||||
} else {
|
||||
void this.store.recordHeartbeat(agentId, "ok", tracked.runId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent is healthy (heartbeat within timeout window).
|
||||
* @param agentId - The agent ID
|
||||
* @returns true if healthy, false if missed heartbeat or not tracked
|
||||
*/
|
||||
isAgentHealthy(agentId: string): boolean {
|
||||
const tracked = this.trackedAgents.get(agentId);
|
||||
if (!tracked) return false;
|
||||
|
||||
const elapsed = Date.now() - tracked.lastSeen;
|
||||
return elapsed < this.heartbeatTimeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of currently tracked agent IDs.
|
||||
* Useful for testing and debugging.
|
||||
*/
|
||||
getTrackedAgents(): string[] {
|
||||
return Array.from(this.trackedAgents.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last seen timestamp for a tracked agent.
|
||||
* @param agentId - The agent ID
|
||||
* @returns Last seen timestamp, or undefined if not tracked
|
||||
*/
|
||||
getLastSeen(agentId: string): number | undefined {
|
||||
return this.trackedAgents.get(agentId)?.lastSeen;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async checkMissedHeartbeats(): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
for (const tracked of this.trackedAgents.values()) {
|
||||
const elapsed = now - tracked.lastSeen;
|
||||
|
||||
if (elapsed >= this.heartbeatTimeoutMs) {
|
||||
// Missed heartbeat detected
|
||||
if (!tracked.missedHeartbeatReported) {
|
||||
tracked.missedHeartbeatReported = true;
|
||||
await this.handleMissedHeartbeat(tracked);
|
||||
} else {
|
||||
// Already reported - check if we should terminate
|
||||
// Give 2x timeout for recovery before auto-terminate
|
||||
if (elapsed >= this.heartbeatTimeoutMs * 2) {
|
||||
await this.terminateUnresponsive(tracked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissedHeartbeat(tracked: TrackedAgent): Promise<void> {
|
||||
// Record missed heartbeat
|
||||
await this.store.recordHeartbeat(tracked.agentId, "missed", tracked.runId);
|
||||
|
||||
// Notify callback
|
||||
this.onMissed?.(tracked.agentId);
|
||||
}
|
||||
|
||||
private async terminateUnresponsive(tracked: TrackedAgent): Promise<void> {
|
||||
// Dispose the session
|
||||
try {
|
||||
tracked.session.dispose();
|
||||
} catch (err) {
|
||||
// Log but don't stop termination
|
||||
console.error(`[HeartbeatMonitor] Error disposing session for ${tracked.agentId}:`, err);
|
||||
}
|
||||
|
||||
// Update agent state to terminated
|
||||
try {
|
||||
await this.store.updateAgentState(tracked.agentId, "terminated");
|
||||
} catch (err) {
|
||||
console.error(`[HeartbeatMonitor] Error terminating agent ${tracked.agentId}:`, err);
|
||||
}
|
||||
|
||||
// Remove from tracking
|
||||
this.trackedAgents.delete(tracked.agentId);
|
||||
|
||||
// Notify callback
|
||||
this.onTerminated?.(tracked.agentId);
|
||||
}
|
||||
}
|
||||
@@ -1304,6 +1304,62 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).not.toContain("> Comment 4");
|
||||
});
|
||||
|
||||
it("end-to-end: steering comments are fully injected into execution prompt with correct format", () => {
|
||||
const now = new Date();
|
||||
const task = createMockTaskDetail({
|
||||
id: "KB-123",
|
||||
title: "Verify Steering Feature",
|
||||
steeringComments: [
|
||||
{
|
||||
id: "sc-001",
|
||||
text: "Please ensure all edge cases are handled in the validation logic",
|
||||
createdAt: new Date(now.getTime() - 120000).toISOString(),
|
||||
author: "user" as const,
|
||||
},
|
||||
{
|
||||
id: "sc-002",
|
||||
text: "Consider adding unit tests for the new utility function",
|
||||
createdAt: new Date(now.getTime() - 60000).toISOString(),
|
||||
author: "agent" as const,
|
||||
},
|
||||
{
|
||||
id: "sc-003",
|
||||
text: "Don't forget to update the documentation before completing",
|
||||
createdAt: now.toISOString(),
|
||||
author: "user" as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildExecutionPrompt(task, "/project", { testCommand: "pnpm test" } as any);
|
||||
|
||||
// Verify section header exists
|
||||
expect(result).toContain("## Steering Comments");
|
||||
|
||||
// Verify explanatory header text
|
||||
expect(result).toContain("The following steering comments were added by the user during execution");
|
||||
expect(result).toContain("Consider adjusting your approach or replanning remaining steps based on this feedback");
|
||||
|
||||
// Verify all three comments appear with correct author badges
|
||||
expect(result).toContain("**user**");
|
||||
expect(result).toContain("**agent**");
|
||||
|
||||
// Verify quoted text format
|
||||
expect(result).toContain("> Please ensure all edge cases are handled in the validation logic");
|
||||
expect(result).toContain("> Consider adding unit tests for the new utility function");
|
||||
expect(result).toContain("> Don't forget to update the documentation before completing");
|
||||
|
||||
// Verify timestamp formatting appears (either relative like "2m ago" or absolute)
|
||||
// The formatTimestamp function returns relative times for recent comments
|
||||
expect(result).toMatch(/\*\*user\*\* — \d+m? ago/);
|
||||
|
||||
// Verify the section appears in the expected location (after progress section, before review level)
|
||||
const steeringSectionIndex = result.indexOf("## Steering Comments");
|
||||
const reviewLevelIndex = result.indexOf("## Review level");
|
||||
expect(steeringSectionIndex).toBeGreaterThan(0);
|
||||
expect(reviewLevelIndex).toBeGreaterThan(steeringSectionIndex);
|
||||
});
|
||||
|
||||
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user