feat(KB-012): add --paused flag to dashboard command

- Add --paused CLI flag to start dashboard in paused mode
- Update runDashboard to accept paused option
- Add comprehensive tests for --paused flag behavior
- Update STANDALONE.md documentation with new flag
- Add changeset for patch release
This commit is contained in:
gsxdsm
2026-03-29 17:48:14 -07:00
parent b1f550ed49
commit d50ee2d98a
5 changed files with 130 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Add `--paused` flag to `kb dashboard` command to start with engine automation disabled. When used, the dashboard starts with `enginePaused` set to true, preventing triage, execution, and auto-merge from running until manually unpaused from the web dashboard settings.

View File

@@ -23,6 +23,7 @@ Launch the web UI and AI engine:
```bash ```bash
kb dashboard kb dashboard
kb dashboard --port 8080 kb dashboard --port 8080
kb dashboard --paused # Start with automation paused (review before work begins)
``` ```
### Create a task ### Create a task

View File

@@ -46,6 +46,7 @@ kb — AI-orchestrated task board
Usage: Usage:
kb dashboard Start the board web UI kb dashboard Start the board web UI
kb dashboard --paused Start with automation paused
kb task create [desc] [opts] Create a new task (goes to triage) kb task create [desc] [opts] Create a new task (goes to triage)
kb task list List all tasks kb task list List all tasks
kb task show <id> Show task details, steps, log kb task show <id> Show task details, steps, log
@@ -60,6 +61,7 @@ Usage:
Options: Options:
--port, -p <port> Dashboard port (default: 4040) --port, -p <port> Dashboard port (default: 4040)
--paused Start with engine paused (automation disabled)
--attach <file> Attach file(s) on task create (repeatable) --attach <file> Attach file(s) on task create (repeatable)
--depends <id> Declare dependency on task create (repeatable) --depends <id> Declare dependency on task create (repeatable)
--limit, -l <n> Max issues to import (default: 30, max: 100) --limit, -l <n> Max issues to import (default: 30, max: 100)
@@ -92,7 +94,8 @@ async function main() {
const pi = portIdx !== -1 ? portIdx : portIdxShort; const pi = portIdx !== -1 ? portIdx : portIdxShort;
const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 4040; const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 4040;
const open = !args.includes("--no-open"); const open = !args.includes("--no-open");
await runDashboard(port, { open }); const paused = args.includes("--paused");
await runDashboard(port, { open, paused });
break; break;
} }

View File

@@ -12,6 +12,7 @@ function makeMockStore() {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined), watch: vi.fn().mockResolvedValue(undefined),
stopWatching: vi.fn(), stopWatching: vi.fn(),
updateSettings: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 1, maxConcurrent: 1,
maxWorktrees: 2, maxWorktrees: 2,
@@ -613,3 +614,115 @@ describe("runDashboard — enginePaused (soft pause)", () => {
expect(mergedIds).toContain("KB-EP2"); expect(mergedIds).toContain("KB-EP2");
}); });
}); });
describe("runDashboard — --paused flag", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@kb/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(_store: unknown, _cwd: unknown, opts: unknown) => {
capturedExecutorOpts = opts as Record<string, unknown>;
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
},
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => {
await runDashboard(0, { open: false, paused: true });
expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
});
it("logs a message when starting in paused mode", async () => {
await runDashboard(0, { open: false, paused: true });
expect(consoleSpy).toHaveBeenCalledWith(
"[engine] Starting in paused mode — automation disabled",
);
});
it("does NOT set enginePaused when paused option is absent", async () => {
await runDashboard(0, { open: false });
// updateSettings should not be called with enginePaused during normal startup
const enginePausedCalls = mockStore.updateSettings.mock.calls.filter(
(call: any[]) => call[0]?.enginePaused !== undefined,
);
expect(enginePausedCalls).toHaveLength(0);
});
it("does NOT log paused message when starting normally", async () => {
await runDashboard(0, { open: false });
const pausedMessageCalls = consoleSpy.mock.calls.filter(
(args) => args[0] === "[engine] Starting in paused mode — automation disabled",
);
expect(pausedMessageCalls).toHaveLength(0);
});
});
describe("runDashboard — --paused flag", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@kb/engine");
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }),
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => {
await runDashboard(0, { open: false, paused: true });
expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
expect(mockStore.updateSettings).toHaveBeenCalledTimes(1);
});
it("does NOT call store.updateSettings when paused flag is absent", async () => {
await runDashboard(0, { open: false });
expect(mockStore.updateSettings).not.toHaveBeenCalled();
});
it("logs paused mode message when starting with paused: true", async () => {
await runDashboard(0, { open: false, paused: true });
expect(consoleSpy).toHaveBeenCalledWith(
"[engine] Starting in paused mode — automation disabled",
);
});
it("does NOT log paused mode message when paused flag is absent", async () => {
await runDashboard(0, { open: false });
const pausedMessageCalls = consoleSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("paused mode"),
);
expect(pausedMessageCalls).toHaveLength(0);
});
});

View File

@@ -13,12 +13,18 @@ function openBrowser(url: string): void {
exec(cmd, () => {}); exec(cmd, () => {});
} }
export async function runDashboard(port: number, opts: { open?: boolean } = {}) { export async function runDashboard(port: number, opts: { open?: boolean; paused?: boolean } = {}) {
const cwd = process.cwd(); const cwd = process.cwd();
const store = new TaskStore(cwd); const store = new TaskStore(cwd);
await store.init(); await store.init();
await store.watch(); await store.watch();
// Set enginePaused if starting in paused mode
if (opts.paused) {
await store.updateSettings({ enginePaused: true });
console.log("[engine] Starting in paused mode — automation disabled");
}
// ── Shared concurrency semaphore ────────────────────────────────── // ── Shared concurrency semaphore ──────────────────────────────────
// //
// Gates all agentic activities (triage, execution, merge) behind a // Gates all agentic activities (triage, execution, merge) behind a