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:
5
.changeset/add-dashboard-paused-flag.md
Normal file
5
.changeset/add-dashboard-paused-flag.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user