feat(KB-618): add multi-project support to dashboard
- Add project API methods and types (fetchProjects, registerProject, fetchProjectHealth, etc.) - Add ProjectCard component with health metrics, status badges, and pause/resume actions - Add server-side project management routes for multi-project orchestration - Add ActivityFeed component with grouped entries and project badges - Add SetupWizard component with 5-step project creation flow - Fix TypeScript errors in server routes for listProjects and getGlobalConcurrencyState
This commit is contained in:
@@ -650,31 +650,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
cronRunner.stop();
|
||||
notifier.stop();
|
||||
if (mergeRetryTimer) clearTimeout(mergeRetryTimer);
|
||||
store.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
stuckTaskDetector.stop();
|
||||
triage.stop();
|
||||
scheduler.stop();
|
||||
cronRunner.stop();
|
||||
notifier.stop();
|
||||
if (mergeRetryTimer) clearTimeout(mergeRetryTimer);
|
||||
store.close();
|
||||
store.stopWatching();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Dev mode: simplified shutdown handlers (no engine components)
|
||||
// Dev mode: simplified SIGINT handler (no engine components)
|
||||
if (opts.dev) {
|
||||
const devShutdown = () => {
|
||||
process.on("SIGINT", () => {
|
||||
notifier.stop();
|
||||
store.close();
|
||||
store.stopWatching();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", devShutdown);
|
||||
process.on("SIGTERM", devShutdown);
|
||||
});
|
||||
}
|
||||
|
||||
const server = app.listen(selectedPort);
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
maxWorkers,
|
||||
fileParallelism: true,
|
||||
fileParallelism: false,
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -14,16 +14,17 @@ import {
|
||||
} from "./backup.js";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
|
||||
// Helper to wait with a delay that ensures different timestamps
|
||||
async function waitForNextSecond(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
}
|
||||
|
||||
describe("BackupManager", () => {
|
||||
let tempDir: string;
|
||||
let kbDir: string;
|
||||
let backupManager: BackupManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use fake timers for deterministic timestamp control
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
kbDir = join(tempDir, ".fusion");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
@@ -33,7 +34,6 @@ describe("BackupManager", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -83,25 +83,20 @@ describe("BackupManager", () => {
|
||||
});
|
||||
|
||||
it("should return sorted array newest-first", async () => {
|
||||
// Create multiple backups by advancing system time deterministically
|
||||
const backup1 = await backupManager.createBackup();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
const backup2 = await backupManager.createBackup();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
|
||||
const backup3 = await backupManager.createBackup();
|
||||
// Create multiple backups with delays to ensure different timestamps
|
||||
await backupManager.createBackup();
|
||||
await waitForNextSecond();
|
||||
await backupManager.createBackup();
|
||||
await waitForNextSecond();
|
||||
await backupManager.createBackup();
|
||||
|
||||
const backups = await backupManager.listBackups();
|
||||
|
||||
expect(backups).toHaveLength(3);
|
||||
// Verify sorted by createdAt descending (newest first)
|
||||
expect(backups[0].createdAt >= backups[1].createdAt).toBe(true);
|
||||
expect(backups[1].createdAt >= backups[2].createdAt).toBe(true);
|
||||
// Verify correct ordering by filename
|
||||
expect(backups[0].filename).toBe(backup3.filename);
|
||||
expect(backups[1].filename).toBe(backup2.filename);
|
||||
expect(backups[2].filename).toBe(backup1.filename);
|
||||
// Verify sorted by createdAt descending
|
||||
for (let i = 0; i < backups.length - 1; i++) {
|
||||
expect(backups[i].createdAt >= backups[i + 1].createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should only list files matching backup pattern", async () => {
|
||||
@@ -128,10 +123,10 @@ describe("BackupManager", () => {
|
||||
|
||||
describe("cleanupOldBackups", () => {
|
||||
it("should not delete when backup count is within retention", async () => {
|
||||
// Create 3 backups with retention of 7 by advancing time
|
||||
// Create 3 backups with retention of 7
|
||||
for (let i = 0; i < 3; i++) {
|
||||
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
|
||||
await backupManager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
const deleted = await backupManager.cleanupOldBackups();
|
||||
@@ -144,10 +139,10 @@ describe("BackupManager", () => {
|
||||
it("should delete oldest backups exceeding retention", async () => {
|
||||
const manager = new BackupManager(kbDir, { retention: 2 });
|
||||
|
||||
// Create 4 backups by advancing time deterministically
|
||||
// Create 4 backups with 1-second delays to ensure different timestamps
|
||||
for (let i = 0; i < 4; i++) {
|
||||
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
|
||||
await manager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
const deleted = await manager.cleanupOldBackups();
|
||||
@@ -155,17 +150,17 @@ describe("BackupManager", () => {
|
||||
expect(deleted).toBe(2); // 4 - 2 = 2 deleted
|
||||
const backups = await manager.listBackups();
|
||||
expect(backups).toHaveLength(2);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it("should keep the newest backups after cleanup", async () => {
|
||||
const manager = new BackupManager(kbDir, { retention: 2 });
|
||||
|
||||
// Create 4 backups and record their names by advancing time
|
||||
// Create 4 backups and record their names
|
||||
const backupNames: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
|
||||
const backup = await manager.createBackup();
|
||||
backupNames.push(backup.filename);
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
await manager.cleanupOldBackups();
|
||||
@@ -205,8 +200,8 @@ describe("BackupManager", () => {
|
||||
it("should create pre-restore backup by default", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
|
||||
// Advance time to ensure different timestamp for pre-restore backup
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
// Wait to ensure different timestamp
|
||||
await waitForNextSecond();
|
||||
|
||||
// Restore with default options (should create pre-restore backup)
|
||||
await backupManager.restoreBackup(backup.filename);
|
||||
@@ -226,19 +221,12 @@ describe("generateBackupFilename", () => {
|
||||
expect(filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
|
||||
});
|
||||
|
||||
it("should generate unique filenames for different timestamps", () => {
|
||||
// Use fake timers for deterministic time control
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
it("should generate unique filenames for different timestamps", async () => {
|
||||
const filename1 = generateBackupFilename();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
await waitForNextSecond();
|
||||
const filename2 = generateBackupFilename();
|
||||
|
||||
expect(filename1).not.toBe(filename2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,10 +294,6 @@ describe("createBackupManager", () => {
|
||||
});
|
||||
|
||||
it("should use settings when provided", async () => {
|
||||
// Use fake timers for deterministic time control
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
const kbDir = join(tempDir, ".fusion");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
@@ -322,19 +306,18 @@ describe("createBackupManager", () => {
|
||||
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
|
||||
// Create 4 backups by advancing time
|
||||
// Create 4 backups with 1-second delays
|
||||
for (let i = 0; i < 4; i++) {
|
||||
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
|
||||
await manager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
// Cleanup should leave only 2
|
||||
const deleted = await manager.cleanupOldBackups();
|
||||
expect(deleted).toBe(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("runBackupCommand", () => {
|
||||
@@ -342,10 +325,6 @@ describe("runBackupCommand", () => {
|
||||
let kbDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use fake timers for deterministic timestamp control
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
kbDir = join(tempDir, ".fusion");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
@@ -353,7 +332,6 @@ describe("runBackupCommand", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -420,15 +398,14 @@ describe("runBackupCommand", () => {
|
||||
autoBackupRetention: 2,
|
||||
};
|
||||
|
||||
// Create 3 backups first (manually to test cleanup) by advancing time
|
||||
// Create 3 backups first (manually to test cleanup) with delays
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
|
||||
await manager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
// Now run backup command
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:03.000Z"));
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
@@ -122,8 +122,6 @@ export class CentralDatabase {
|
||||
|
||||
// Enable WAL mode for concurrent reader/writer access
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
// Ensure data reaches disk on commit (NORMAL is safe with WAL mode)
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
// Enable foreign key enforcement
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
@@ -146,14 +144,8 @@ export class CentralDatabase {
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
* Checkpoints the WAL first to ensure all writes are flushed to the main db file.
|
||||
*/
|
||||
close(): void {
|
||||
try {
|
||||
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} catch {
|
||||
// Best-effort: checkpoint failure is non-fatal
|
||||
}
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -109,7 +109,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -683,8 +683,8 @@ describe("schema migrations", () => {
|
||||
// Now run init() which should trigger migration
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 4 (includes v1→v2, v2→v3, and v3→v4 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
// Verify version bumped to 3 (includes both v1→v2 and v2→v3 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -709,11 +709,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -804,11 +804,11 @@ describe("schema migrations", () => {
|
||||
// Insert a task on the v2 schema
|
||||
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-2', 'test v2', 'triage', '2025-01-01', '2025-01-01')`);
|
||||
|
||||
// Now run init() which should trigger migrations v2→v3→v4
|
||||
// Now run init() which should trigger v2→v3 migration
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 4
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
// Verify version bumped to 3
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -864,7 +864,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -58,7 +58,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 4;
|
||||
const SCHEMA_VERSION = 3;
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
-- Tasks table with JSON columns for nested data
|
||||
@@ -75,7 +75,6 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
blockedBy TEXT,
|
||||
paused INTEGER DEFAULT 0,
|
||||
baseBranch TEXT,
|
||||
baseCommitSha TEXT,
|
||||
modelPresetId TEXT,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
@@ -100,8 +99,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
issueInfo TEXT,
|
||||
mergeDetails TEXT,
|
||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]',
|
||||
modifiedFiles TEXT DEFAULT '[]'
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]'
|
||||
);
|
||||
|
||||
-- Config table (single row with project settings)
|
||||
@@ -264,8 +262,6 @@ export class Database {
|
||||
|
||||
// Enable WAL mode for concurrent reader/writer access
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
// Ensure data reaches disk on commit (NORMAL is safe with WAL mode)
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
// Enable foreign key enforcement
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
@@ -326,15 +322,6 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 4) {
|
||||
this.applyMigration(4, () => {
|
||||
// Add modifiedFiles column to track files changed during agent execution
|
||||
this.addColumnIfMissing("tasks", "modifiedFiles", "TEXT DEFAULT '[]'");
|
||||
// Add baseCommitSha column to store the base commit for diff computation
|
||||
this.addColumnIfMissing("tasks", "baseCommitSha", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if (version < 3) { this.applyMigration(3, () => { ... }); }
|
||||
}
|
||||
@@ -370,25 +357,10 @@ export class Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkpoint the WAL file back into the main database.
|
||||
* This reduces the risk of corruption from incomplete WAL writes
|
||||
* and keeps the WAL file from growing unbounded.
|
||||
*/
|
||||
checkpoint(): void {
|
||||
try {
|
||||
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} catch {
|
||||
// Best-effort: checkpoint failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
* Checkpoints the WAL first to ensure all writes are flushed to the main db file.
|
||||
*/
|
||||
close(): void {
|
||||
this.checkpoint();
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
blockedBy: row.blockedBy || undefined,
|
||||
paused: row.paused ? true : undefined,
|
||||
baseBranch: row.baseBranch || undefined,
|
||||
baseCommitSha: row.baseCommitSha || undefined,
|
||||
modelPresetId: row.modelPresetId || undefined,
|
||||
modelProvider: row.modelProvider || undefined,
|
||||
modelId: row.modelId || undefined,
|
||||
@@ -158,7 +157,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
|
||||
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
|
||||
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
|
||||
modifiedFiles: (() => { const m = fromJson<string[]>(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,15 +167,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.db.prepare(`
|
||||
INSERT OR REPLACE INTO tasks (
|
||||
id, title, description, "column", status, size, reviewLevel, currentStep,
|
||||
worktree, blockedBy, paused, baseBranch, baseCommitSha, modelPresetId, modelProvider,
|
||||
worktree, blockedBy, paused, baseBranch, modelPresetId, modelProvider,
|
||||
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles
|
||||
breakIntoSubtasks, enabledWorkflowSteps
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`).run(
|
||||
task.id,
|
||||
@@ -192,7 +190,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.blockedBy ?? null,
|
||||
task.paused ? 1 : 0,
|
||||
task.baseBranch ?? null,
|
||||
task.baseCommitSha ?? null,
|
||||
task.modelPresetId ?? null,
|
||||
task.modelProvider ?? null,
|
||||
task.modelId ?? null,
|
||||
@@ -217,7 +214,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJsonNullable(task.mergeDetails),
|
||||
task.breakIntoSubtasks ? 1 : 0,
|
||||
toJson(task.enabledWorkflowSteps || []),
|
||||
toJson(task.modifiedFiles || []),
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
@@ -879,7 +875,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null },
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null },
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
// Validate that task doesn't depend on itself
|
||||
@@ -929,7 +925,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
if (updates.paused !== undefined) task.paused = updates.paused || undefined;
|
||||
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
|
||||
if (updates.baseCommitSha !== undefined) task.baseCommitSha = updates.baseCommitSha;
|
||||
if (updates.size !== undefined) task.size = updates.size;
|
||||
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
|
||||
if (updates.mergeRetries !== undefined) task.mergeRetries = updates.mergeRetries;
|
||||
@@ -968,11 +963,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.workflowStepResults !== undefined) {
|
||||
task.workflowStepResults = updates.workflowStepResults;
|
||||
}
|
||||
if (updates.modifiedFiles === null) {
|
||||
task.modifiedFiles = undefined;
|
||||
} else if (updates.modifiedFiles !== undefined) {
|
||||
task.modifiedFiles = updates.modifiedFiles;
|
||||
}
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
@@ -1475,10 +1465,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
paused: task.paused,
|
||||
baseBranch: task.baseBranch,
|
||||
baseCommitSha: task.baseCommitSha,
|
||||
mergeRetries: task.mergeRetries,
|
||||
error: task.error,
|
||||
modifiedFiles: task.modifiedFiles,
|
||||
};
|
||||
|
||||
// Write to archivedTasks table in SQLite
|
||||
@@ -1693,18 +1681,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.recentlyWritten.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shut down: stop watching and close the database connection.
|
||||
* Ensures WAL is checkpointed so no pending writes are lost.
|
||||
*/
|
||||
close(): void {
|
||||
this.stopWatching();
|
||||
if (this._db) {
|
||||
this._db.close();
|
||||
this._db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a file path as recently written by an in-process mutation
|
||||
* so the watcher will skip it.
|
||||
@@ -2319,10 +2295,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
paused: task.paused,
|
||||
baseBranch: task.baseBranch,
|
||||
baseCommitSha: task.baseCommitSha,
|
||||
mergeRetries: task.mergeRetries,
|
||||
error: task.error,
|
||||
modifiedFiles: task.modifiedFiles,
|
||||
};
|
||||
|
||||
// Write to archivedTasks table
|
||||
@@ -2385,8 +2359,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
validatorModelProvider: entry.validatorModelProvider,
|
||||
validatorModelId: entry.validatorModelId,
|
||||
breakIntoSubtasks: entry.breakIntoSubtasks,
|
||||
modifiedFiles: entry.modifiedFiles,
|
||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, baseCommitSha, error, steeringComments
|
||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, error, steeringComments
|
||||
};
|
||||
|
||||
// Write task.json
|
||||
|
||||
@@ -387,10 +387,6 @@ export interface Task {
|
||||
* unmerged branch. The executor reads this to branch from the
|
||||
* dependency's branch instead of HEAD. Cleared after worktree creation. */
|
||||
baseBranch?: string;
|
||||
/** Commit SHA of the base branch at worktree creation time.
|
||||
* Used for computing file diffs when reviewing task changes.
|
||||
* Set by the executor when creating the worktree. */
|
||||
baseCommitSha?: string;
|
||||
attachments?: TaskAttachment[];
|
||||
steeringComments?: SteeringComment[];
|
||||
comments?: TaskComment[];
|
||||
@@ -432,8 +428,6 @@ export interface Task {
|
||||
error?: string;
|
||||
/** Optional summary of what was changed/fixed when task is completed */
|
||||
summary?: string;
|
||||
/** Files modified during agent execution, captured at task completion time */
|
||||
modifiedFiles?: string[];
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||
columnMovedAt?: string;
|
||||
@@ -867,11 +861,8 @@ export interface ArchivedTaskEntry {
|
||||
breakIntoSubtasks?: boolean;
|
||||
paused?: boolean;
|
||||
baseBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
mergeRetries?: number;
|
||||
error?: string;
|
||||
/** Files modified during agent execution, captured at task completion time */
|
||||
modifiedFiles?: string[];
|
||||
}
|
||||
|
||||
/** Type of planning question presented to the user */
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
maxWorkers,
|
||||
fileParallelism: true,
|
||||
fileParallelism: false,
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
|
||||
@@ -1868,14 +1868,3 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
|
||||
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
|
||||
}
|
||||
|
||||
/** Diff information for a task */
|
||||
export interface TaskDiff {
|
||||
files: string[];
|
||||
diffs: Record<string, { stat: string; patch: string }>;
|
||||
}
|
||||
|
||||
/** Fetch diff information for a task */
|
||||
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
|
||||
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function ListView({
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
return true; // Default: hide done tasks
|
||||
return false; // Default: show done tasks
|
||||
});
|
||||
|
||||
// Collapsed sections state - initialize from localStorage
|
||||
@@ -724,7 +724,6 @@ export function ListView({
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
autoExpand={false}
|
||||
/>
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
interface MergeDetailsProps {
|
||||
task: Task;
|
||||
|
||||
@@ -163,36 +163,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isOpen, view]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -244,6 +214,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
[view]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ const providerConfig: Record<
|
||||
> = {
|
||||
anthropic: { component: AnthropicIcon, color: "#d4a27f" }, // warm tan
|
||||
openai: { component: OpenAIIcon, color: "#10a37f" }, // green
|
||||
"openai-codex": { component: OpenAIIcon, color: "#10a37f" }, // green (same as openai)
|
||||
google: { component: GeminiIcon, color: "#4285f4" }, // blue
|
||||
gemini: { component: GeminiIcon, color: "#4285f4" }, // blue (same as google)
|
||||
ollama: { component: OllamaIcon, color: "#fff" }, // white
|
||||
|
||||
@@ -22,11 +22,6 @@ interface QuickEntryBoxProps {
|
||||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
/**
|
||||
* When false, the component will not auto-expand on focus.
|
||||
* Defaults to true for backward compatibility.
|
||||
*/
|
||||
autoExpand?: boolean;
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
@@ -49,7 +44,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) || "";
|
||||
@@ -314,11 +309,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Only auto-expand if autoExpand prop is true (defaults to true for backward compatibility)
|
||||
if (autoExpand) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [autoExpand]);
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Clear any existing timeout
|
||||
|
||||
@@ -79,9 +79,6 @@ export function SettingsModal({
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
// Auth state (independent of the settings save flow)
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
@@ -342,6 +339,9 @@ export function SettingsModal({
|
||||
[onClose],
|
||||
);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (prefixError || presetDraft) return;
|
||||
try {
|
||||
|
||||
@@ -637,16 +637,9 @@ function TaskCardComponent({
|
||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Truncate title/description to 140 chars with ellipsis; full text in tooltip */}
|
||||
{(() => {
|
||||
const displayText = task.title || task.description || task.id;
|
||||
const truncatedText = truncate(displayText, 140);
|
||||
return (
|
||||
<div className="card-title" title={displayText}>
|
||||
{truncatedText}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="card-title">
|
||||
{task.title || task.description || task.id}
|
||||
</div>
|
||||
{task.steps.length > 0 && (() => {
|
||||
const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const totalSteps = task.steps.length;
|
||||
@@ -742,9 +735,5 @@ function TaskCardComponent({
|
||||
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
||||
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
||||
TaskCard.displayName = "TaskCard";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@fusion/core";
|
||||
import type { Task, TaskComment } from "@kb/core";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -106,7 +105,7 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "changes" | "steering" | "comments" | "model">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -672,14 +671,6 @@ export function TaskDetailModal({
|
||||
>
|
||||
Agent Log
|
||||
</button>
|
||||
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "changes" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("changes")}
|
||||
>
|
||||
Changes
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("steering")}
|
||||
@@ -712,8 +703,6 @@ export function TaskDetailModal({
|
||||
validatorModel={getValidatorSelection(task)}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "changes" ? (
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} />
|
||||
) : activeTab === "steering" ? (
|
||||
<SteeringTab task={task} addToast={addToast} />
|
||||
) : activeTab === "comments" ? (
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("Board", () => {
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]");
|
||||
expect(todoTasks[0].title).toBe("Updated");
|
||||
expect(columnRenderCounts.todo).toBeGreaterThan(initialTodoRenders);
|
||||
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
|
||||
expect(columnRenderCounts.done).toBe(initialDoneRenders);
|
||||
});
|
||||
|
||||
it("filtered tasks are sorted correctly (columnMovedAt, createdAt)", () => {
|
||||
@@ -292,7 +292,7 @@ describe("Board", () => {
|
||||
expect(todoTasks).toHaveLength(3);
|
||||
|
||||
// Tasks with columnMovedAt should come first, sorted by columnMovedAt descending (newest first)
|
||||
// So FN-002 (12:00) should be first, FN-001 (10:00) second
|
||||
// So KB-002 (12:00) should be first, KB-001 (10:00) second
|
||||
// Legacy tasks (no columnMovedAt) come last, sorted by createdAt ascending
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
expect(todoTasks[1].id).toBe("FN-001");
|
||||
@@ -302,7 +302,7 @@ describe("Board", () => {
|
||||
it("matches tasks across multiple fields simultaneously", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
|
||||
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "KB-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
|
||||
];
|
||||
|
||||
@@ -313,7 +313,7 @@ describe("Board", () => {
|
||||
|
||||
// Should match both tasks with "search" in ID, title, or description
|
||||
expect(todoTasks).toHaveLength(2);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["KB-999", "SEARCH-123"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from search query", () => {
|
||||
|
||||
@@ -991,9 +991,8 @@ describe("GitManagerModal", () => {
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "upstream");
|
||||
|
||||
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
|
||||
expect(saveButton).toBeTruthy();
|
||||
await user.click(saveButton as HTMLButtonElement);
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameGitRemote).toHaveBeenCalledWith("origin", "upstream");
|
||||
@@ -1024,9 +1023,8 @@ describe("GitManagerModal", () => {
|
||||
await user.clear(urlInput);
|
||||
await user.type(urlInput, "https://new-url.com/repo.git");
|
||||
|
||||
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
|
||||
expect(saveButton).toBeTruthy();
|
||||
await user.click(saveButton as HTMLButtonElement);
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateGitRemoteUrl).toHaveBeenCalledWith("origin", "https://new-url.com/repo.git");
|
||||
|
||||
@@ -300,12 +300,12 @@ describe("InlineCreateCard model selector", () => {
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard([], { availableModels: undefined });
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -46,7 +46,6 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -227,10 +226,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const columnHeader = screen.getByText("Column");
|
||||
fireEvent.click(columnHeader);
|
||||
|
||||
@@ -315,15 +310,11 @@ describe("ListView", () => {
|
||||
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
|
||||
const tasks = columns.map((col, i) =>
|
||||
createMockTask({ id: `FN-00${i + 1}`, column: col })
|
||||
createMockTask({ id: `KB-00${i + 1}`, column: col })
|
||||
);
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks in the table
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that all column badges are rendered in the table
|
||||
// Use getAllByText and check length since column names appear in both drop zones and badges
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -569,10 +560,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections including Done and Archived
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that section headers are rendered with column names
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -590,10 +577,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Find section headers by their structure
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6); // One for each column
|
||||
@@ -668,7 +651,6 @@ describe("ListView", () => {
|
||||
describe("ListView Column Filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("filters tasks by column when drop zone is clicked", () => {
|
||||
@@ -718,10 +700,6 @@ describe("ListView Column Filtering", () => {
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Show Done" to reveal all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All 6 section headers should be visible (one for each column)
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6);
|
||||
@@ -1041,26 +1019,13 @@ describe("ListView Hide Done Tasks", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders hide done tasks toggle button with 'Show Done' when done tasks are hidden by default", () => {
|
||||
it("renders hide done tasks toggle button", () => {
|
||||
renderListView();
|
||||
|
||||
const hideDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
expect(hideDoneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks by default when no localStorage value exists", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
createMockTask({ id: "FN-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks when toggle is activated", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
@@ -1069,15 +1034,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show done tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1094,15 +1055,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show archived tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide archived tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1120,16 +1077,12 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all completed tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All tasks should be visible now
|
||||
// All tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
expect(screen.getByText("FN-003")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide completed tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1148,13 +1101,16 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Completed tasks should be hidden by default
|
||||
// Click hide done button to hide completed tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Completed tasks should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
|
||||
// Click "Show Done" to show all tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
// Click again to show all tasks
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// All tasks should be visible again
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
@@ -1166,11 +1122,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" first (since default is now hidden)
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1207,7 +1159,14 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Stats should show filtered count with hidden indicator (default is now hidden)
|
||||
// Initial stats should show all tasks
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Stats should show filtered count with hidden indicator
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
expect(screen.getByText(/2 hidden/)).toBeDefined();
|
||||
});
|
||||
@@ -1221,7 +1180,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done and Archived sections should be hidden by default
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done and Archived sections should be hidden
|
||||
const doneSection = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Done")
|
||||
);
|
||||
@@ -1247,7 +1214,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done drop zone should still be visible with "X of Y" format
|
||||
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
|
||||
expect(doneZone).toBeDefined();
|
||||
expect(doneZone?.textContent).toContain("0 of 2");
|
||||
@@ -1261,7 +1232,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived drop zone should still be visible with "X of Y" format
|
||||
const archivedZone = document.querySelector('[data-column="archived"].list-drop-zone');
|
||||
expect(archivedZone).toBeDefined();
|
||||
expect(archivedZone?.textContent).toContain("0 of 2");
|
||||
@@ -1276,11 +1251,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Hide done tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Apply filter
|
||||
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||
fireEvent.change(filterInput, { target: { value: "Gamma" } });
|
||||
|
||||
// Completed tasks should remain hidden (hide done is active by default)
|
||||
// Completed tasks should remain hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
// Filtered task should be visible
|
||||
@@ -1295,7 +1274,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the done drop zone to select that column
|
||||
@@ -1315,7 +1298,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the archived drop zone to select that column
|
||||
@@ -1331,7 +1318,6 @@ describe("ListView Hide Done Tasks", () => {
|
||||
describe("ListView Quick Entry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders QuickEntryBox when onQuickCreate is provided", () => {
|
||||
@@ -1769,7 +1755,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
const checkboxes = screen.getAllByLabelText(/Select KB-/);
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1779,11 +1765,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
// Click "Show Done" to make archived tasks visible
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1794,7 +1776,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
@@ -1806,7 +1788,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
|
||||
@@ -1845,7 +1827,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
|
||||
@@ -1867,7 +1849,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const applyButton = screen.getByText("Apply");
|
||||
@@ -1878,7 +1860,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(localStorage.getItem("kb-dashboard-selected-tasks")).toBe('["FN-001"]');
|
||||
@@ -1891,7 +1873,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
const checkboxes = screen.getAllByLabelText(/Select KB-/);
|
||||
// Select only first task
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
@@ -1919,7 +1901,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
);
|
||||
|
||||
// Select the task
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Initially disabled
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("NewTaskModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created FN-042", "success");
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created KB-042", "success");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,12 +15,6 @@ describe("ProviderIcon", () => {
|
||||
expect(screen.getByLabelText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders OpenAI brand icon for openai-codex provider", () => {
|
||||
render(<ProviderIcon provider="openai-codex" />);
|
||||
expect(screen.getByTestId("openai-icon")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Gemini brand icon for google provider", () => {
|
||||
render(<ProviderIcon provider="google" />);
|
||||
expect(screen.getByTestId("gemini-icon")).toBeInTheDocument();
|
||||
@@ -77,12 +71,6 @@ describe("ProviderIcon", () => {
|
||||
expect(icon).toHaveStyle({ color: "#10a37f" });
|
||||
});
|
||||
|
||||
it("applies provider-specific color for openai-codex", () => {
|
||||
render(<ProviderIcon provider="openai-codex" />);
|
||||
const icon = screen.getByTestId("openai-icon").parentElement;
|
||||
expect(icon).toHaveStyle({ color: "#10a37f" });
|
||||
});
|
||||
|
||||
it("applies provider-specific color for google", () => {
|
||||
render(<ProviderIcon provider="google" />);
|
||||
const icon = screen.getByTestId("gemini-icon").parentElement;
|
||||
@@ -159,14 +147,6 @@ describe("ProviderIcon", () => {
|
||||
expect(paths[0]).toHaveAttribute("fill", "#10a37f");
|
||||
});
|
||||
|
||||
it("passes correct color to SVG fill for openai-codex", () => {
|
||||
render(<ProviderIcon provider="openai-codex" />);
|
||||
const svg = screen.getByTestId("openai-icon");
|
||||
const paths = svg.querySelectorAll("path");
|
||||
expect(paths.length).toBeGreaterThan(0);
|
||||
expect(paths[0]).toHaveAttribute("fill", "#10a37f");
|
||||
});
|
||||
|
||||
it("passes correct color to SVG fill for gemini", () => {
|
||||
render(<ProviderIcon provider="gemini" />);
|
||||
const svg = screen.getByTestId("gemini-icon");
|
||||
|
||||
@@ -174,16 +174,6 @@ describe("QuickEntryBox", () => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expand on focus when autoExpand is false", () => {
|
||||
renderQuickEntryBox({ autoExpand: false });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
// Should not expand when autoExpand is false
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses on blur when empty", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -689,7 +689,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
// Check that no elements in the settings content have inline styles
|
||||
const elementsWithStyle = container.querySelectorAll("[style]");
|
||||
expect(elementsWithStyle.length).toBe(1);
|
||||
expect(elementsWithStyle.length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
|
||||
@@ -833,14 +833,14 @@ describe("SettingsModal", () => {
|
||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has .settings-sidebar with 12 .settings-nav-item buttons for all sections", async () => {
|
||||
it("has .settings-sidebar with 11 .settings-nav-item buttons for all sections", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(12);
|
||||
expect(navItems.length).toBe(11);
|
||||
|
||||
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
@@ -848,7 +848,6 @@ describe("SettingsModal", () => {
|
||||
"📁General",
|
||||
"🌐Model",
|
||||
"📁Model Presets",
|
||||
"📁AI Summarization",
|
||||
"🌐Appearance",
|
||||
"📁Scheduling",
|
||||
"📁Worktrees",
|
||||
|
||||
@@ -487,7 +487,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
|
||||
}
|
||||
|
||||
it("generates correct tooltip text", () => {
|
||||
expect(computeScopeTooltip("FN-005")).toBe("Blocked by FN-005 (file overlap)");
|
||||
expect(computeScopeTooltip("FN-005")).toBe("Blocked by KB-005 (file overlap)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -647,7 +647,7 @@ describe("TaskCard clickable dependencies", () => {
|
||||
fireEvent.click(depBadge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2295,17 +2295,18 @@ describe("TaskCard GitHub badges", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for task detail opening behavior in TaskCard.
|
||||
* The card body opens the modal directly; there is no separate expand button.
|
||||
* Tests for expand button and modal open behavior in TaskCard.
|
||||
* Ensures that clicking the expand button opens the modal,
|
||||
* while clicking the card body does not.
|
||||
*/
|
||||
describe("TaskCard detail opening", () => {
|
||||
describe("TaskCard expand button", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens modal when clicking the card body", async () => {
|
||||
it("opens modal when clicking the expand button", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
@@ -2329,8 +2330,11 @@ describe("TaskCard detail opening", () => {
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
const cardTitle = screen.getByText("Test task");
|
||||
fireEvent.click(cardTitle);
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
|
||||
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
@@ -2338,24 +2342,10 @@ describe("TaskCard detail opening", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a separate expand button", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens modal only once per card click", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
it("does NOT open modal when clicking the card body", async () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const task = makeTask();
|
||||
|
||||
const task = makeTask({ title: "Test Task Title" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
@@ -2365,13 +2355,35 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Test task"));
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Click on the card title (part of card body)
|
||||
const cardTitle = screen.getByText("Test Task Title");
|
||||
fireEvent.click(cardTitle);
|
||||
|
||||
// Wait for any async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Modal should NOT have opened
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expand button has correct accessibility attributes", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.getAttribute("aria-label")).toBe("Open task details");
|
||||
expect(expandButton.getAttribute("title")).toBe("Open task details");
|
||||
});
|
||||
|
||||
it("does NOT open modal during vertical scrolling", async () => {
|
||||
@@ -2472,7 +2484,7 @@ describe("TaskCard detail opening", () => {
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render an expand button in any column", () => {
|
||||
it("expand button is present in all columns", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
for (const column of columns) {
|
||||
@@ -2486,11 +2498,49 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
|
||||
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("expand button stops propagation to prevent double-triggering", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
const task = makeTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
|
||||
// Click the expand button - should only trigger once
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -2564,148 +2614,3 @@ describe("TaskCard title display", () => {
|
||||
expect(cardTitle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for TaskCard title truncation to 140 characters.
|
||||
*/
|
||||
describe("TaskCard title truncation", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as Task);
|
||||
|
||||
it("displays short title unchanged (under 140 characters)", () => {
|
||||
const shortTitle = "This is a short title";
|
||||
const task = makeTask({ title: shortTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitle = screen.getByText(shortTitle);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent).toBe(shortTitle);
|
||||
});
|
||||
|
||||
it("displays title exactly 140 characters unchanged", () => {
|
||||
const exactTitle = "A".repeat(140);
|
||||
const task = makeTask({ title: exactTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitle = screen.getByText(exactTitle);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent?.length).toBe(140);
|
||||
expect(cardTitle.textContent).toBe(exactTitle);
|
||||
});
|
||||
|
||||
it("truncates title over 140 characters with ellipsis", () => {
|
||||
const longTitle = "B".repeat(150);
|
||||
const task = makeTask({ title: longTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated text with ellipsis (140 chars + "…")
|
||||
const expectedTruncated = "B".repeat(140) + "…";
|
||||
const cardTitle = screen.getByText(expectedTruncated);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent?.length).toBe(141); // 140 + ellipsis
|
||||
});
|
||||
|
||||
it("truncates description fallback when no title and description is over 140 chars", () => {
|
||||
const longDescription = "C".repeat(200);
|
||||
const task = makeTask({ title: undefined, description: longDescription });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated description with ellipsis
|
||||
const expectedTruncated = "C".repeat(140) + "…";
|
||||
const cardTitle = screen.getByText(expectedTruncated);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent?.length).toBe(141);
|
||||
});
|
||||
|
||||
it("includes full untruncated text in title attribute for tooltip", () => {
|
||||
const longTitle = "D".repeat(200);
|
||||
const task = makeTask({ title: longTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// The title attribute should contain the full untruncated text
|
||||
const cardTitleElement = document.querySelector(".card-title");
|
||||
expect(cardTitleElement).toBeDefined();
|
||||
expect(cardTitleElement?.getAttribute("title")).toBe(longTitle);
|
||||
});
|
||||
|
||||
it("includes full description in title attribute when using description fallback", () => {
|
||||
const longDescription = "E".repeat(200);
|
||||
const task = makeTask({ title: undefined, description: longDescription });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitleElement = document.querySelector(".card-title");
|
||||
expect(cardTitleElement).toBeDefined();
|
||||
expect(cardTitleElement?.getAttribute("title")).toBe(longDescription);
|
||||
});
|
||||
|
||||
it("includes task id in title attribute when falling back to id", () => {
|
||||
const task = makeTask({ title: undefined, description: "" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitleElement = document.querySelector(".card-title");
|
||||
expect(cardTitleElement).toBeDefined();
|
||||
expect(cardTitleElement?.getAttribute("title")).toBe("FN-001");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1341,7 +1341,7 @@ describe("TaskDetailModal", () => {
|
||||
renderWithSearch();
|
||||
fireEvent.click(screen.getByText("Add Dependency"));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "fn-020" } });
|
||||
fireEvent.change(input, { target: { value: "kb-020" } });
|
||||
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -1465,7 +1465,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(depLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1805,7 +1805,7 @@ describe("TaskDetailModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — KB-001 moved to Todo", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1846,7 +1846,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Plan rejected — FN-001 returned to Triage for re-specification",
|
||||
"Plan rejected — KB-001 returned to Triage for re-specification",
|
||||
"info"
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -2013,7 +2013,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Duplicate FN-001? This will create a new task in Triage with the same description and prompt."
|
||||
"Duplicate KB-001? This will create a new task in Triage with the same description and prompt."
|
||||
);
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
@@ -2072,7 +2072,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated KB-001 → KB-002", "success");
|
||||
});
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
@@ -2393,7 +2393,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: KB-002", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2682,7 +2682,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Updated KB-001", "success");
|
||||
});
|
||||
|
||||
// Should exit edit mode
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("useAgentLogs", () => {
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/KB-001/logs/stream");
|
||||
});
|
||||
|
||||
it("appends live SSE entries to historical entries", async () => {
|
||||
|
||||
@@ -110,8 +110,8 @@ describe("useMultiAgentLogs", () => {
|
||||
await waitFor(() => {
|
||||
// Filter to unique URLs (Strict Mode may create duplicates)
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("useMultiAgentLogs", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["FN-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Clear only FN-001
|
||||
// Clear only KB-001
|
||||
act(() => {
|
||||
result.current["FN-001"].clear();
|
||||
});
|
||||
|
||||
@@ -809,140 +809,4 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0].column).toBe("todo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibility change", () => {
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original descriptor to restore later
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original visibilityState property
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
// If no original descriptor, just delete our mock
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchVisibilityChange() {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
it("refetches tasks when visibility changes from hidden to visible", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
|
||||
const refreshedTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress" as Column,
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Reset mock to return refreshed data
|
||||
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
setVisibilityState("visible");
|
||||
setVisibilityState("hidden");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
// Should not trigger another fetch
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Wait for 1 second to ensure debounce window has passed from initial fetch
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Reset mock to track new calls
|
||||
mockFetchTasks.mockClear();
|
||||
|
||||
// First visibility change should trigger a fetch (1s has passed)
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rapid visibility changes immediately after should be debounced
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
// Should still only be 1 call (debounced)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,41 +30,11 @@ export function useTasks() {
|
||||
const tasksRef = useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
// Ref to track last visibility fetch time for debouncing (1 second minimum)
|
||||
const lastVisibilityFetchRef = useRef<number>(0);
|
||||
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
|
||||
|
||||
// Fetch initial tasks
|
||||
useEffect(() => {
|
||||
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
|
||||
}, []);
|
||||
|
||||
// Visibility change listener - refresh tasks when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastVisibilityFetchRef.current;
|
||||
|
||||
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
|
||||
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
|
||||
lastVisibilityFetchRef.current = now;
|
||||
api.fetchTasks()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => {
|
||||
// Silently ignore fetch errors on visibility change
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSE live updates
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
try {
|
||||
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
|
||||
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'solarized', 'factory', 'ayu', 'one-dark'];
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'monochrome', 'high-contrast', 'solarized', 'factory', 'ayu', 'one-dark'];
|
||||
if (!validThemes.includes(colorTheme)) {
|
||||
colorTheme = 'default';
|
||||
}
|
||||
|
||||
@@ -382,7 +382,6 @@ body {
|
||||
gap: var(--column-gap);
|
||||
padding: var(--board-padding);
|
||||
height: calc(100vh - 57px);
|
||||
height: calc(100dvh - 57px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
@@ -4798,11 +4797,6 @@ body {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Set width on ID column header to match data cells */
|
||||
.list-table th:nth-child(2).list-header-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.list-header-cell:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -4864,7 +4858,6 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-id {
|
||||
width: 70px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -4874,6 +4867,7 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -5133,9 +5127,7 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.list-cell-date {
|
||||
@@ -10562,9 +10554,6 @@ html .column.drag-over * {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Main layout: sidebar + content */
|
||||
@@ -11662,7 +11651,7 @@ html .column.drag-over * {
|
||||
.gm-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -11699,7 +11688,7 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
.gm-content {
|
||||
min-height: 200px;
|
||||
min-height: 300px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -11812,134 +11801,3 @@ html .column.drag-over * {
|
||||
[data-theme="light"] .gm-load-more:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* ── Task Changes Tab Styles ─────────────────────────────────────────────── */
|
||||
|
||||
.task-changes-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.changes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.changes-header h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.changes-file-list {
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changes-file-item {
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.changes-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.changes-file-item.expanded {
|
||||
background: var(--bg-secondary, #161b22);
|
||||
}
|
||||
|
||||
.changes-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.changes-file-header:hover {
|
||||
background: var(--bg-hover, #1f242c);
|
||||
}
|
||||
|
||||
.changes-file-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.changes-file-stat {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.changes-file-content {
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
|
||||
.changes-diff-patch {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
}
|
||||
|
||||
.changes-diff-patch code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for diff */
|
||||
.changes-diff-patch .diff-add,
|
||||
.changes-diff-patch [data-prefix="+"] {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-del,
|
||||
.changes-diff-patch [data-prefix="-"] {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-hunk,
|
||||
.changes-diff-patch [data-prefix="@@"] {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
FeatureCreateInput,
|
||||
MissionStatus,
|
||||
MilestoneStatus,
|
||||
SliceStatus,
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -35,6 +37,14 @@ import {
|
||||
INTERVIEW_STATES,
|
||||
} from "@fusion/core";
|
||||
|
||||
// ── Param Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract a route param as string (Express 5 params can be string | string[]) */
|
||||
function param(req: Request, name: string): string {
|
||||
const val = req.params[name];
|
||||
return Array.isArray(val) ? val[0] : val;
|
||||
}
|
||||
|
||||
// ── Validation Utilities ────────────────────────────────────────────────────
|
||||
|
||||
function validateUuid(id: string): boolean {
|
||||
@@ -174,7 +184,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -198,7 +208,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
const { title, description, status } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -243,7 +253,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -268,7 +278,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/status",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -295,7 +305,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -319,7 +329,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
const { state } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -351,7 +361,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/milestones",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -378,7 +388,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/milestones",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
const { title, description, dependencies } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -414,7 +424,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/milestones/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const missionId = param(req, "missionId");
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -456,7 +466,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -480,7 +490,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
const { title, description, status, dependencies } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -528,7 +538,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -555,7 +565,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -579,7 +589,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
const { state } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -611,7 +621,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId/slices",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -638,7 +648,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/slices",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
const { title, description } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -672,7 +682,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/slices/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
const milestoneId = param(req, "milestoneId");
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -714,7 +724,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
const sliceId = param(req, "sliceId");
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -738,7 +748,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
const sliceId = param(req, "sliceId");
|
||||
const { title, description, status } = req.body;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
@@ -783,7 +793,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
const sliceId = param(req, "sliceId");
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -808,7 +818,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/slices/:sliceId/activate",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
const sliceId = param(req, "sliceId");
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -837,7 +847,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/slices/:sliceId/features",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
const sliceId = param(req, "sliceId");
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -862,7 +872,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/slices/:sliceId/features",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
const sliceId = param(req, "sliceId");
|
||||
const { title, description, acceptanceCriteria } = req.body;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
@@ -898,7 +908,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { featureId } = req.params;
|
||||
const featureId = param(req, "featureId");
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
@@ -922,7 +932,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { featureId } = req.params;
|
||||
const featureId = param(req, "featureId");
|
||||
const { title, description, acceptanceCriteria, status } = req.body;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
@@ -970,7 +980,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { featureId } = req.params;
|
||||
const featureId = param(req, "featureId");
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
@@ -995,7 +1005,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/features/:featureId/link-task",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { featureId } = req.params;
|
||||
const featureId = param(req, "featureId");
|
||||
const { taskId } = req.body;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
@@ -1034,7 +1044,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/features/:featureId/unlink-task",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { featureId } = req.params;
|
||||
const featureId = param(req, "featureId");
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
|
||||
@@ -33,18 +33,6 @@ function createMockGlobalSettingsStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore() {
|
||||
return {
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
|
||||
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
addAnswer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
@@ -75,7 +63,6 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -3678,10 +3665,7 @@ describe("Git Management endpoints", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use the actual project root so git commands work
|
||||
store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue(process.cwd()),
|
||||
});
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
|
||||
@@ -1841,82 +1841,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id/diff
|
||||
* Get detailed diff information for files modified during task execution.
|
||||
* Returns: { files: string[]; diffs: Record<string, { stat: string; patch: string }> }
|
||||
*/
|
||||
router.get("/tasks/:id/diff", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
|
||||
// Only tasks with worktrees can have diffs
|
||||
if (!task.worktree || !existsSync(task.worktree)) {
|
||||
res.json({ files: [], diffs: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use stored modifiedFiles if available, otherwise compute on-the-fly
|
||||
let files = task.modifiedFiles;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
// Fallback: compute files using git diff
|
||||
try {
|
||||
const baseRef = task.baseCommitSha ?? "HEAD~1";
|
||||
const output = execSync(`git diff --name-only ${baseRef}..HEAD`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
files = output ? output.split("\n").filter(Boolean) : [];
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
res.json({ files: [], diffs: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute diffs for each file
|
||||
const diffs: Record<string, { stat: string; patch: string }> = {};
|
||||
const baseRef = task.baseCommitSha ?? "HEAD~1";
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Get stat for this file
|
||||
const stat = execSync(`git diff --stat ${baseRef}..HEAD -- "${file}"`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
// Get patch for this file
|
||||
const patch = execSync(`git diff ${baseRef}..HEAD -- "${file}"`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
diffs[file] = { stat, patch };
|
||||
} catch (err: any) {
|
||||
// Log error but continue with other files
|
||||
console.warn(`Failed to get diff for ${file}:`, err.message);
|
||||
diffs[file] = { stat: "", patch: "" };
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, diffs });
|
||||
} 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 /api/tasks/:id/workflow-results
|
||||
* Get workflow step execution results for a task.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import { createServer } from "./server.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
@@ -24,43 +23,63 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getMissionStore: vi.fn().mockReturnValue({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||||
createMilestone: vi.fn(),
|
||||
updateMilestone: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
deleteMilestone: vi.fn(),
|
||||
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||||
createMissionTask: vi.fn(),
|
||||
updateMissionTask: vi.fn(),
|
||||
getMissionTask: vi.fn(),
|
||||
deleteMissionTask: vi.fn(),
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
|
||||
const res = await performGet(app, path);
|
||||
return res;
|
||||
/** Helper: send GET and return { status, body, headers } */
|
||||
async function GET(app: express.Express, path: string): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
http.get(`http://127.0.0.1:${addr.port}${path}`, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
server.close();
|
||||
try {
|
||||
resolve({ status: res.statusCode!, body: JSON.parse(data), headers: res.headers });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data, headers: res.headers });
|
||||
}
|
||||
});
|
||||
}).on("error", (err) => { server.close(); reject(err); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function REQUEST(
|
||||
app: ReturnType<typeof createServer>,
|
||||
app: express.Express,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
|
||||
return performRequest(app, method, path, body, headers);
|
||||
): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const req = http.request(
|
||||
{ hostname: "127.0.0.1", port: addr.port, path, method, headers },
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
server.close();
|
||||
try {
|
||||
resolve({ status: res.statusCode!, body: JSON.parse(data), headers: res.headers });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data, headers: res.headers });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", (err) => { server.close(); reject(err); });
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("API Error Handling Middleware", () => {
|
||||
|
||||
@@ -176,280 +176,6 @@ describe("usage", () => {
|
||||
expect(sessionWindow!.resetText).toContain("resets in");
|
||||
});
|
||||
|
||||
it("handles 429 rate limit with retry - succeeds on second attempt", async () => {
|
||||
// Use fake timers for controlled retry delays
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
five_hour: { utilization: 50, resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() },
|
||||
};
|
||||
|
||||
let requestCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestCount++;
|
||||
const mockRes = {
|
||||
statusCode: requestCount === 1 ? 429 : 200, // First request fails with 429
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(requestCount === 1 ? { error: "rate limited" } : mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providersPromise = fetchAllProviderUsage();
|
||||
|
||||
// Advance timers to let retry delays complete (1s for first retry)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
const providers = await providersPromise;
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(requestCount).toBe(2); // Initial + 1 retry
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.windows).toHaveLength(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fails after max retries exhausted on 429", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
let requestCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestCount++;
|
||||
const mockRes = {
|
||||
statusCode: 429, // Always rate limited
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "rate limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providersPromise = fetchAllProviderUsage();
|
||||
|
||||
// Advance through all retry delays: 1s + 2s + 4s = 7s
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
|
||||
const providers = await providersPromise;
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(requestCount).toBe(3); // Max 3 attempts
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Rate limited by Anthropic API");
|
||||
expect(claude.error).toContain("please try again in a few moments");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not retry on 401 auth errors - fails immediately", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "expired-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
let requestCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestCount++;
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "unauthorized"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(requestCount).toBe(1); // No retries
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
});
|
||||
|
||||
it("does not retry on 403 auth errors - fails immediately", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "forbidden-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
let requestCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestCount++;
|
||||
const mockRes = {
|
||||
statusCode: 403,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "forbidden"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(requestCount).toBe(1); // No retries
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
});
|
||||
|
||||
it("does not retry on 5xx server errors - fails immediately", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
let requestCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestCount++;
|
||||
const mockRes = {
|
||||
statusCode: 503, // Service unavailable
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "service unavailable"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(requestCount).toBe(1); // No retries
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toBe("HTTP 503");
|
||||
});
|
||||
|
||||
it("retries with exponential backoff delays (1s, 2s, 4s)", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
let requestCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestCount++;
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "rate limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providersPromise = fetchAllProviderUsage();
|
||||
|
||||
// Advance through all retry delays: 1s + 2s + 4s = 7s
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
|
||||
const providers = await providersPromise;
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
// Should make 3 attempts (initial + 2 retries) with exponential backoff
|
||||
expect(requestCount).toBe(3);
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Rate limited by Anthropic API");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("handles 401 auth error", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
|
||||
@@ -207,24 +207,8 @@ function decodeJwtPayload(token: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ── Claude fetcher ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch Claude usage data from Anthropic API.
|
||||
*
|
||||
* Implements retry logic with exponential backoff for rate limit (429) errors:
|
||||
* - Max 3 attempts total (initial + 2 retries)
|
||||
* - Delays: 1s, 2s, 4s (exponential backoff)
|
||||
* - Auth errors (401/403) and server errors (5xx) fail immediately without retry
|
||||
* - After max retries exhausted, returns user-friendly rate limit error
|
||||
*/
|
||||
async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Claude",
|
||||
@@ -271,98 +255,78 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
else usage.plan = oauthCreds.rateLimitTier;
|
||||
}
|
||||
|
||||
// Retry logic with exponential backoff for 429 errors
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1000; // 1s, 2s, 4s
|
||||
try {
|
||||
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${oauthCreds.accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${oauthCreds.accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — run 'claude' to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
// Rate limited - retry with exponential backoff (1s, 2s, 4s)
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); // 1s, 2s, 4s
|
||||
await sleep(delayMs);
|
||||
continue; // Retry
|
||||
}
|
||||
// All retries exhausted
|
||||
usage.status = "error";
|
||||
usage.error = "Rate limited by Anthropic API — please try again in a few moments";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.body);
|
||||
usage.status = "ok";
|
||||
|
||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
|
||||
const w = data[key];
|
||||
if (!w || typeof w !== "object") return null;
|
||||
|
||||
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
|
||||
let resetText: string | null = null;
|
||||
let resetMs: number | undefined;
|
||||
|
||||
const resetAt = w.resets_at || w.reset_at || w.resetAt;
|
||||
if (resetAt) {
|
||||
const msLeft = new Date(resetAt).getTime() - Date.now();
|
||||
resetMs = msLeft > 0 ? msLeft : 0;
|
||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
||||
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
|
||||
resetText,
|
||||
windowDurationMs,
|
||||
resetMs,
|
||||
};
|
||||
};
|
||||
|
||||
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
|
||||
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
|
||||
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
|
||||
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
|
||||
|
||||
if (fiveHour) usage.windows.push(fiveHour);
|
||||
if (sevenDay) usage.windows.push(sevenDay);
|
||||
if (sonnet) usage.windows.push(sonnet);
|
||||
if (opus) usage.windows.push(opus);
|
||||
|
||||
// Success - exit retry loop
|
||||
return usage;
|
||||
} catch (e: any) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
usage.error = "Auth expired — run 'claude' to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
usage.status = "error";
|
||||
usage.error = "Rate limited — try again later";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.body);
|
||||
usage.status = "ok";
|
||||
|
||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
|
||||
const w = data[key];
|
||||
if (!w || typeof w !== "object") return null;
|
||||
|
||||
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
|
||||
let resetText: string | null = null;
|
||||
let resetMs: number | undefined;
|
||||
|
||||
const resetAt = w.resets_at || w.reset_at || w.resetAt;
|
||||
if (resetAt) {
|
||||
const msLeft = new Date(resetAt).getTime() - Date.now();
|
||||
resetMs = msLeft > 0 ? msLeft : 0;
|
||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
||||
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
|
||||
resetText,
|
||||
windowDurationMs,
|
||||
resetMs,
|
||||
};
|
||||
};
|
||||
|
||||
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
|
||||
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
|
||||
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
|
||||
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
|
||||
|
||||
if (fiveHour) usage.windows.push(fiveHour);
|
||||
if (sevenDay) usage.windows.push(sevenDay);
|
||||
if (sonnet) usage.windows.push(sonnet);
|
||||
if (opus) usage.windows.push(opus);
|
||||
} catch (e: any) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
}
|
||||
|
||||
// Should not reach here, but return error just in case
|
||||
usage.status = "error";
|
||||
usage.error = "Rate limited by Anthropic API — please try again in a few moments";
|
||||
return usage;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
maxWorkers,
|
||||
fileParallelism: true,
|
||||
fileParallelism: false,
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
|
||||
@@ -3,36 +3,34 @@ import { vi } from "vitest";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock: Record<string, string> = {};
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => localStorageMock[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageMock[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete localStorageMock[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
|
||||
},
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => localStorageMock[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageMock[key] = value;
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
removeItem: (key: string) => {
|
||||
delete localStorageMock[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)" ? true : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)" ? true : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Global MockEventSource for tests
|
||||
class MockEventSource {
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --exclude src/executor.test.ts && pnpm run test:executor",
|
||||
"test:executor": "vitest run src/executor.test.ts -t \"TaskExecutor with semaphore|TaskExecutor worktreeInitCommand|TaskExecutor worktree naming\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree recovery\" && vitest run src/executor.test.ts -t \"TaskExecutor dependency-based worktree creation\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree pool integration|WorktreePool capacity|Merger worktree pool integration\" && vitest run src/executor.test.ts -t \"buildExecutionPrompt|summarizeToolArgs|TaskExecutor pause behavior|TaskExecutor global pause behavior|TaskExecutor enginePaused soft pause\" && vitest run src/executor.test.ts -t \"Code review verdict|RETHINK verdict handling|Plan RETHINK verdict handling|task_add_dep tool|TaskExecutor usage limit detection|Per-task model overrides|Invalid transition error handling|TaskExecutor task_done with summary|Workflow Steps Execution|Real-time steering injection\""
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
|
||||
@@ -8,27 +8,6 @@ vi.mock("./pi.js", () => ({
|
||||
vi.mock("./reviewer.js", () => ({
|
||||
reviewStep: vi.fn(),
|
||||
}));
|
||||
vi.mock("./logger.js", () => {
|
||||
const createMockLogger = () => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
});
|
||||
return {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
schedulerLog: createMockLogger(),
|
||||
executorLog: createMockLogger(),
|
||||
triageLog: createMockLogger(),
|
||||
mergerLog: createMockLogger(),
|
||||
worktreePoolLog: createMockLogger(),
|
||||
reviewerLog: createMockLogger(),
|
||||
prMonitorLog: createMockLogger(),
|
||||
runtimeLog: createMockLogger(),
|
||||
ipcLog: createMockLogger(),
|
||||
projectManagerLog: createMockLogger(),
|
||||
hybridExecutorLog: createMockLogger(),
|
||||
};
|
||||
});
|
||||
vi.mock("./merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./merger.js")>();
|
||||
return {
|
||||
@@ -473,7 +452,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
|
||||
// Should use task ID (lowercase) as worktree name
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-042", {
|
||||
worktree: "/tmp/test/.worktrees/fn-042",
|
||||
worktree: "/tmp/test/.worktrees/kb-042",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using task-id
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
@@ -644,7 +623,8 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
// Should have logged worktree creation
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Worktree created at"),
|
||||
expect.stringContaining("Worktree created"),
|
||||
expect.stringContaining(".worktrees/"),
|
||||
);
|
||||
// execSync should be called for worktree creation
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
@@ -678,8 +658,8 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
// Should have logged cleanup and retry
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
||||
"/tmp/test/.worktrees/swift-falcon",
|
||||
expect.stringContaining("Cleaned up conflicting worktree"),
|
||||
"/tmp/test/.worktrees/green-sage",
|
||||
);
|
||||
// Should eventually succeed
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
@@ -907,7 +887,8 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Removed stale branch reference, retrying"),
|
||||
expect.stringContaining("Removed stale branch"),
|
||||
"fusion/fn-050",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -941,6 +922,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Removing existing directory (not a registered worktree)"),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1005,7 +987,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-060",
|
||||
baseBranch: "kb/fn-059",
|
||||
baseBranch: "fusion/fn-059",
|
||||
}));
|
||||
|
||||
// The git worktree add command should include the startPoint
|
||||
@@ -1013,7 +995,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
||||
);
|
||||
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||
expect(worktreeAddCalls[0][0]).toContain("kb/fn-059");
|
||||
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
|
||||
});
|
||||
|
||||
it("creates worktree from HEAD when baseBranch is not set", async () => {
|
||||
@@ -1043,12 +1025,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-062",
|
||||
baseBranch: "kb/fn-061",
|
||||
baseBranch: "fusion/fn-061",
|
||||
}));
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-062",
|
||||
expect.stringContaining("based on kb/fn-061"),
|
||||
expect.stringContaining("based on fusion/fn-061"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1075,13 +1057,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
let firstAttempt = true;
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b") && firstAttempt) {
|
||||
if (cmd === 'git worktree add -b "fusion/fn-064" "/tmp/test/.worktrees/swift-falcon"' && firstAttempt) {
|
||||
firstAttempt = false;
|
||||
const err: any = new Error(
|
||||
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -1095,12 +1077,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
|
||||
);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
'git branch -D "kb/fn-064"',
|
||||
'git branch -D "fusion/fn-064"',
|
||||
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
|
||||
);
|
||||
|
||||
const worktreeCreateCalls = mockedExecSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"),
|
||||
(call) => call[0] === 'git worktree add -b "fusion/fn-064" "/tmp/test/.worktrees/swift-falcon"',
|
||||
);
|
||||
expect(worktreeCreateCalls).toHaveLength(2);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -1115,37 +1097,30 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (cmd === 'git worktree add -b "kb/fn-065" "/tmp/test/.worktrees/swift-falcon"') {
|
||||
if (cmd === 'git worktree add -b "fusion/fn-065" "/tmp/test/.worktrees/swift-falcon"') {
|
||||
const err: any = new Error(
|
||||
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'fusion/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
if (cmd === `git worktree remove "${conflictingPath}" --force`) {
|
||||
throw new Error("remove failed");
|
||||
}
|
||||
if (cmd === 'git branch -D "kb/fn-065"') {
|
||||
throw new Error("branch delete failed");
|
||||
}
|
||||
if (cmd === "git worktree list --porcelain") {
|
||||
return Buffer.from(`/tmp/test/.git/worktrees/sharp-stone\n`);
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await executor.execute(makeTask({ id: "FN-065" }));
|
||||
|
||||
// After 3 retry attempts, should fail with combined error message
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
error: expect.stringContaining("Worktree conflict"),
|
||||
error: expect.stringContaining("already used by worktree"),
|
||||
});
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
error: expect.stringContaining("automatic cleanup failed"),
|
||||
error: expect.stringContaining("automatic cleanup failed: remove failed"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1172,13 +1147,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-064",
|
||||
baseBranch: "kb/fn-063",
|
||||
baseBranch: "fusion/fn-063",
|
||||
}));
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"kb/fn-064",
|
||||
"kb/fn-063",
|
||||
"fusion/fn-064",
|
||||
"fusion/fn-063",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1209,7 +1184,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"kb/fn-065",
|
||||
"fusion/fn-065",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1516,7 +1491,7 @@ describe("buildExecutionPrompt", () => {
|
||||
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**screenshot.png** (screenshot)");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/KB-001/attachments/abc123-screenshot.png");
|
||||
});
|
||||
|
||||
it("includes attachment section with absolute paths for text attachments", () => {
|
||||
@@ -1530,7 +1505,7 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**error.log** (text/plain)");
|
||||
expect(result).toContain("read for context");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/def456-error.log");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/KB-001/attachments/def456-error.log");
|
||||
});
|
||||
|
||||
it("includes both image and text attachments", () => {
|
||||
@@ -3197,7 +3172,7 @@ describe("task_add_dep tool", () => {
|
||||
|
||||
await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-specification");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on KB-OTHER — stopping execution for re-specification");
|
||||
});
|
||||
|
||||
it("appends to existing dependencies without overwriting when confirm=true", async () => {
|
||||
@@ -3338,7 +3313,7 @@ describe("task_add_dep tool", () => {
|
||||
|
||||
// Branch deletion should have been attempted
|
||||
const branchDeleteCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("kb/fn-dep"),
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("fusion/fn-dep"),
|
||||
);
|
||||
expect(branchDeleteCalls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -4550,3 +4525,4 @@ describe("Real-time steering injection", () => {
|
||||
await executePromise;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -449,31 +449,12 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (task.worktree) {
|
||||
// Task already had a worktree assigned and it exists on disk — reuse it
|
||||
executorLog.log(`Reusing existing worktree: ${worktreePath}`);
|
||||
} else {
|
||||
// Directory exists at generated path but task has no worktree — create via normal flow
|
||||
worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
|
||||
isResume = existsSync(worktreePath);
|
||||
worktreePath = await this.createWorktree(branchName, worktreePath, task.id);
|
||||
}
|
||||
|
||||
// Capture the base commit SHA for diff computation
|
||||
// This is done after worktree creation when we're on the new branch
|
||||
if (!task.baseCommitSha) {
|
||||
try {
|
||||
const baseCommitSha = execSync("git rev-parse HEAD", {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
await this.store.updateTask(task.id, { baseCommitSha });
|
||||
executorLog.log(`${task.id}: captured baseCommitSha ${baseCommitSha.slice(0, 7)}`);
|
||||
} catch (err: any) {
|
||||
executorLog.log(`Failed to capture baseCommitSha for ${task.id}: ${err.message}`);
|
||||
// Non-fatal: task can continue without baseCommitSha
|
||||
}
|
||||
}
|
||||
|
||||
this.activeWorktrees.set(task.id, worktreePath);
|
||||
|
||||
this.options.onStart?.(task, worktreePath);
|
||||
@@ -588,14 +569,6 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
if (taskDone) {
|
||||
// Capture modified files before running workflow steps
|
||||
const updatedTask = await this.store.getTask(task.id);
|
||||
const modifiedFiles = this.captureModifiedFiles(worktreePath, updatedTask.baseCommitSha);
|
||||
if (modifiedFiles.length > 0) {
|
||||
await this.store.updateTask(task.id, { modifiedFiles });
|
||||
executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
|
||||
}
|
||||
|
||||
// Run workflow steps before moving to in-review
|
||||
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||
if (!workflowSuccess) {
|
||||
@@ -1092,61 +1065,6 @@ export class TaskExecutor {
|
||||
await this.store.logEntry(taskId, "Execution stopped — work discarded, moved to triage for re-specification");
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the list of files modified during agent execution.
|
||||
* Uses git diff against the stored baseCommitSha to determine what changed.
|
||||
* Returns an empty array if no changes or if git commands fail.
|
||||
*/
|
||||
private captureModifiedFiles(worktreePath: string, baseCommitSha?: string): string[] {
|
||||
try {
|
||||
// Determine the base reference for diff
|
||||
// If baseCommitSha is stored, use it; otherwise fall back to merge-base with HEAD
|
||||
let baseRef = baseCommitSha;
|
||||
if (!baseRef) {
|
||||
// Try to find merge-base with main/master as fallback
|
||||
try {
|
||||
baseRef = execSync("git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main", {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
// If merge-base fails, use HEAD~1 as last resort
|
||||
try {
|
||||
baseRef = execSync("git rev-parse HEAD~1", {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
executorLog.log(`Could not determine base commit for diff in ${worktreePath}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!baseRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get list of modified files using git diff --name-only
|
||||
const output = execSync(`git diff --name-only ${baseRef}..HEAD`, {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return output.split("\n").filter(Boolean);
|
||||
} catch (err: any) {
|
||||
executorLog.log(`Failed to capture modified files: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worktree management ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,15 +24,11 @@ export {
|
||||
type RuntimeMetrics,
|
||||
type ProjectRuntimeEvents,
|
||||
type GlobalMetrics,
|
||||
type TaskExecutionResult,
|
||||
type RuntimeHealth,
|
||||
type RuntimeEventType,
|
||||
} from "./project-runtime.js";
|
||||
|
||||
export { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
||||
export { ChildProcessRuntime } from "./runtimes/child-process-runtime.js";
|
||||
export { ProjectManager, type ProjectManagerEvents } from "./project-manager.js";
|
||||
export { HybridExecutor, type HybridExecutorEvents, type HybridExecutorOptions } from "./hybrid-executor.js";
|
||||
|
||||
// ── IPC Protocol ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -73,6 +73,3 @@ export const ipcLog = createLogger("ipc");
|
||||
|
||||
/** Logger for the project manager subsystem. */
|
||||
export const projectManagerLog = createLogger("project-manager");
|
||||
|
||||
/** Logger for the hybrid executor subsystem. */
|
||||
export const hybridExecutorLog = createLogger("hybrid-executor");
|
||||
|
||||
@@ -339,7 +339,7 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
||||
(call) => String(call[0]).includes("git commit"),
|
||||
);
|
||||
expect(commitCall).toBeDefined();
|
||||
expect(String(commitCall![0])).toContain("feat(FN-050):");
|
||||
expect(String(commitCall![0])).toContain("feat(KB-050):");
|
||||
});
|
||||
|
||||
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
|
||||
|
||||
@@ -128,53 +128,6 @@ export interface ProjectRuntime extends EventEmitter<ProjectRuntimeEvents> {
|
||||
getMetrics(): RuntimeMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a task execution operation.
|
||||
*/
|
||||
export interface TaskExecutionResult {
|
||||
/** Whether the task execution was successful */
|
||||
success: boolean;
|
||||
/** The task ID that was executed */
|
||||
taskId: string;
|
||||
/** Error message if execution failed */
|
||||
error?: string;
|
||||
/** Number of steps completed during execution */
|
||||
stepsCompleted?: number;
|
||||
/** ISO-8601 timestamp when execution started */
|
||||
startedAt?: string;
|
||||
/** ISO-8601 timestamp when execution completed */
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health metrics for a ProjectRuntime instance.
|
||||
* Used for detailed health monitoring and diagnostics.
|
||||
*/
|
||||
export interface RuntimeHealth {
|
||||
/** Current status of the runtime */
|
||||
status: RuntimeStatus;
|
||||
/** Number of tasks currently in-progress */
|
||||
activeTasks: number;
|
||||
/** Memory usage in bytes */
|
||||
memoryUsage: number;
|
||||
/** ISO-8601 timestamp of the last activity */
|
||||
lastActivityAt: string;
|
||||
/** Number of errors encountered */
|
||||
errorCount: number;
|
||||
/** Optional last error message */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime event types for event handlers.
|
||||
*/
|
||||
export type RuntimeEventType =
|
||||
| "task:created"
|
||||
| "task:completed"
|
||||
| "task:failed"
|
||||
| "health:changed"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* Global metrics aggregated across all project runtimes.
|
||||
*/
|
||||
|
||||
@@ -27,9 +27,6 @@ vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn().mockResolvedValue("# Task prompt content"),
|
||||
}));
|
||||
|
||||
import { TaskExecutor } from "./executor.js";
|
||||
import { TriageProcessor } from "./triage.js";
|
||||
@@ -76,7 +73,6 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
|
||||
updateStep: vi.fn().mockImplementation(async (id: string, step: number, status: StepStatus) => {
|
||||
return makeTaskDetail(id, "in-progress");
|
||||
}),
|
||||
@@ -281,7 +277,7 @@ describe("In-review merge handling after restart", () => {
|
||||
store.getTask.mockResolvedValue(makeTaskDetail("FN-050", "in-progress"));
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
"Cannot merge FN-050: task is in 'in-progress', must be in 'in-review'",
|
||||
"Cannot merge KB-050: task is in 'in-progress', must be in 'in-review'",
|
||||
);
|
||||
|
||||
// No git commands should have been executed
|
||||
@@ -355,7 +351,7 @@ describe("In-review merge handling after restart", () => {
|
||||
} as any);
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-055")).rejects.toThrow(
|
||||
"AI merge failed for FN-055: all 3 attempts exhausted",
|
||||
"AI merge failed for KB-055: all 3 attempts exhausted",
|
||||
);
|
||||
|
||||
// Should have attempted git reset --merge cleanup
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
maxWorkers,
|
||||
fileParallelism: true,
|
||||
fileParallelism: false,
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
|
||||
Reference in New Issue
Block a user