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:
gsxdsm
2026-03-31 12:51:06 -07:00
parent 782412d1fd
commit 9af06c81dc
17 changed files with 5379 additions and 56 deletions

View 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>;
}
}

File diff suppressed because it is too large Load Diff

View 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");

View File

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

View File

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

View File

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

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

View File

@@ -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 && (

View File

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

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

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

View File

@@ -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 () => {

View File

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

View File

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

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

View File

@@ -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({