feat: add debug command for debugging schedule generation

This commit is contained in:
2026-06-06 23:32:48 +09:00
parent c422a0c0d4
commit a19fc3e508
10 changed files with 635 additions and 74 deletions

View File

@@ -1,2 +1,11 @@
// Manage brains
import type { Command } from "commander";
import { registerCommand } from "@/commands";
export async function brain() {}
export function register(program: Command): Command {
return registerCommand(program, {
name: "brain",
description: "Manage brains",
}).action(brain);
}

View File

@@ -0,0 +1,12 @@
import type { Command } from "commander";
import { registerCommand } from "@/commands";
import { addScheduleSubcommand } from "./schedule";
export function register(program: Command): Command {
return registerCommand(program, {
name: "debug",
description:
"Dry-run tools: exercise code paths without writing to the database or braindb",
configure: addScheduleSubcommand,
});
}

View File

@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import type { Availability, DailySchedule, MonthlySchedule } from "@/brain/schedule";
interface RecordedCall {
model: unknown;
options: { jsonSchemaName?: string; message?: string };
}
const llmCalls: RecordedCall[] = [];
function build48Slots(): DailySchedule {
const slots: DailySchedule = [];
for (let i = 0; i < 48; i++) {
const startHour = Math.floor(i / 2);
const startMin = (i % 2) * 30;
const start = `${String(startHour).padStart(2, "0")}:${String(startMin).padStart(2, "0")}`;
let end: string;
if (i === 47) {
end = "24:00";
} else {
const endHour = Math.floor((i + 1) / 2);
const endMin = ((i + 1) % 2) * 30;
end = `${String(endHour).padStart(2, "0")}:${String(endMin).padStart(2, "0")}`;
}
slots.push({ start, end, activity: `slot-${i}` });
}
return slots;
}
function buildAvailability(): Availability[] {
return [
{ start: "00:00", end: "07:00", status: "offline" },
{ start: "07:00", end: "23:30", status: "online" },
{ start: "23:30", end: "24:00", status: "offline" },
];
}
function buildMonthly(): MonthlySchedule {
return Array.from({ length: 30 }, (_, i) => ({
day: i + 1,
summary: `Day ${i + 1} summary`,
}));
}
const mockCall = mock(async (_model: unknown, options: any) => {
llmCalls.push({ model: _model, options });
if (options.jsonSchemaName === "daily-schedule") return build48Slots();
if (options.jsonSchemaName === "monthly-schedule") {
const match = (options.message as string).match(/\((\d+) days\)/);
const days = match ? parseInt(match[1]!, 10) : 30;
return Array.from({ length: days }, (_, i) => ({
day: i + 1,
summary: `Day ${i + 1} summary`,
}));
}
if (options.jsonSchemaName === "availability") return buildAvailability();
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
});
mock.module("@/openrouter", () => ({
llm: {
models: { conversation: "test-conv", identity: "test-id" },
call: mockCall,
},
}));
mock.module("@/config", () => ({
config: {
openrouterApiKey: "test-key",
dbPath: ":memory:",
braindbPath: "/tmp/brainbox-test-braindb-debug-schedule.json",
},
}));
const { runDebugScheduleDaily, runDebugScheduleMonthly } = await import(
"./schedule"
);
beforeEach(() => {
llmCalls.length = 0;
mockCall.mockClear();
});
afterEach(async () => {
// ensure no on-disk braindb was created
const { unlink } = await import("fs/promises");
try {
await unlink("/tmp/brainbox-test-braindb-debug-schedule.json");
} catch {}
});
describe("runDebugScheduleDaily", () => {
test("T1: returns ok result with schedule and availability, uses the supplied personality", async () => {
const result = await runDebugScheduleDaily({
message: "focus on writing",
personality: "test-personality-XYZ",
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("expected ok");
expect(result.kind).toBe("daily");
expect(result.schedule).toHaveLength(48);
expect(result.availability.length).toBeGreaterThan(0);
expect(result.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const dailyCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "daily-schedule",
);
expect(dailyCall).toBeDefined();
expect(dailyCall!.options.message).toContain("test-personality-XYZ");
expect(dailyCall!.options.message).toContain("focus on writing");
const availCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "availability",
);
expect(availCall).toBeDefined();
});
test("T2: when LLM returns null for daily, returns {ok: false, error}", async () => {
mockCall.mockImplementationOnce(async () => null as unknown as never);
const result = await runDebugScheduleDaily({
message: "",
personality: "p",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected !ok");
expect(result.error).toMatch(/Daily schedule generation failed/);
});
});
describe("runDebugScheduleMonthly", () => {
test("T3: returns ok result with monthly schedule, uses the supplied personality", async () => {
const result = await runDebugScheduleMonthly({
message: "study for GRE",
personality: "test-personality-ABC",
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("expected ok");
expect(result.kind).toBe("monthly");
expect(result.schedule).toHaveLength(result.daysInMonth);
expect(result.monthKey).toMatch(/^\d{4}-\d{2}$/);
const call = llmCalls.find(
(c) => c.options.jsonSchemaName === "monthly-schedule",
);
expect(call).toBeDefined();
expect(call!.options.message).toContain("test-personality-ABC");
expect(call!.options.message).toContain("study for GRE");
});
test("T4: when LLM returns null for monthly, returns {ok: false, error}", async () => {
mockCall.mockImplementationOnce(async () => null as unknown as never);
const result = await runDebugScheduleMonthly({
message: "",
personality: "p",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected !ok");
expect(result.error).toMatch(/Monthly schedule generation failed/);
});
});
describe("debug schedule no-disk invariant", () => {
test("T5: running a debug schedule does not create a brainbox db file on disk", async () => {
const { existsSync } = await import("fs");
const { resolve } = await import("path");
const beforeDb = existsSync(resolve(process.cwd(), "brainbox.db"));
const beforeJson = existsSync(resolve(process.cwd(), "brainbox.json"));
await runDebugScheduleDaily({ message: "m", personality: "p" });
const afterDb = existsSync(resolve(process.cwd(), "brainbox.db"));
const afterJson = existsSync(resolve(process.cwd(), "brainbox.json"));
expect(beforeDb).toBe(false);
expect(beforeJson).toBe(false);
expect(afterDb).toBe(false);
expect(afterJson).toBe(false);
});
});

View File

@@ -0,0 +1,171 @@
import type { Command } from "commander";
import ora from "ora";
import { Brain } from "@/brain";
import { logger } from "@/utils/logger";
import {
type Availability,
type DailySchedule,
type MonthlySchedule,
formatDateKey,
nextMonth,
pad2,
} from "@/brain/schedule";
export interface ScheduleOptions {
message: string;
personality: string;
}
export type DailyRunResult =
| {
ok: true;
kind: "daily";
dateKey: string;
tomorrow: Date;
schedule: DailySchedule;
availability: Availability[];
}
| { ok: false; error: string };
export type MonthlyRunResult =
| {
ok: true;
kind: "monthly";
monthKey: string;
daysInMonth: number;
schedule: MonthlySchedule;
}
| { ok: false; error: string };
export async function runDebugScheduleDaily(
opts: ScheduleOptions,
): Promise<DailyRunResult> {
const today = new Date();
const tomorrow = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 1,
);
const dateKey = formatDateKey(tomorrow);
const brain = await Brain.createDebug({ personality: opts.personality });
const scheduleSpinner = ora(
`Generating daily schedule for ${dateKey}...`,
).start();
const schedule = await brain.createDailySchedule(today, opts.message);
if (!schedule) {
scheduleSpinner.fail("Daily schedule generation failed");
return { ok: false, error: "Daily schedule generation failed" };
}
scheduleSpinner.succeed(
`Daily schedule generated (${schedule.length} slots)`,
);
printSection(
`Daily Schedule — ${dateKey} (${tomorrow.toLocaleDateString("en-US", { weekday: "long" })})`,
);
console.log(JSON.stringify(schedule, null, 2));
const availSpinner = ora("Deriving availability...").start();
const availability = await brain.deriveAvailabilityFromSchedule(schedule);
if (!availability) {
availSpinner.fail("Availability derivation failed");
return { ok: false, error: "Availability derivation failed" };
}
availSpinner.succeed(
`Availability derived (${availability.length} windows)`,
);
printSection(`Availability — ${dateKey}`);
console.log(JSON.stringify(availability, null, 2));
logger.info("Debug run complete. Nothing was written to disk.");
return {
ok: true,
kind: "daily",
dateKey,
tomorrow,
schedule,
availability,
};
}
export async function runDebugScheduleMonthly(
opts: ScheduleOptions,
): Promise<MonthlyRunResult> {
const today = new Date();
const next = nextMonth(today);
const monthKey = `${next.year}-${pad2(next.month + 1)}`;
const brain = await Brain.createDebug({ personality: opts.personality });
const scheduleSpinner = ora(
`Generating monthly schedule for ${monthKey} (${next.daysInMonth} days)...`,
).start();
const schedule = await brain.createMonthlySchedule(today, opts.message);
if (!schedule) {
scheduleSpinner.fail("Monthly schedule generation failed");
return { ok: false, error: "Monthly schedule generation failed" };
}
scheduleSpinner.succeed(
`Monthly schedule generated (${schedule.length} day summaries)`,
);
printSection(`Monthly Schedule — ${monthKey} (${next.daysInMonth} days)`);
console.log(JSON.stringify(schedule, null, 2));
logger.info(
"Debug run complete. Nothing was written to disk. (Availability applies per-day and is not generated for the monthly view.)",
);
return {
ok: true,
kind: "monthly",
monthKey,
daysInMonth: next.daysInMonth,
schedule,
};
}
export function addScheduleSubcommand(parent: Command): Command {
const cmd = parent
.command("schedule")
.description("Generate a test schedule (no disk writes)");
cmd.command("daily")
.description(
"Generate a daily schedule for tomorrow and print schedule + availability",
)
.requiredOption("-m, --message <text>", "User direction for the schedule")
.requiredOption("-p, --personality <text>", "Brain personality to use")
.action(async (opts: ScheduleOptions) => {
const result = await runDebugScheduleDaily(opts);
if (!result.ok) {
logger.error(result.error);
process.exit(1);
}
});
cmd.command("monthly")
.description("Generate a monthly schedule for next month and print it")
.requiredOption("-m, --message <text>", "User direction for the schedule")
.requiredOption("-p, --personality <text>", "Brain personality to use")
.action(async (opts: ScheduleOptions) => {
const result = await runDebugScheduleMonthly(opts);
if (!result.ok) {
logger.error(result.error);
process.exit(1);
}
});
return cmd;
}
function printSection(title: string): void {
const line = "─".repeat(Math.max(40, title.length + 4));
console.log(`\n┌${line}`);
console.log(`${title}`);
console.log(`${line}`);
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import { Command } from "commander";
import { registerCommand } from "./index";
describe("registerCommand", () => {
test("attaches a subcommand with the given name and description", () => {
const program = new Command();
const cmd = registerCommand(program, {
name: "foo",
description: "Foo command",
});
expect(cmd).toBeDefined();
expect(cmd.name()).toBe("foo");
expect(cmd.description()).toBe("Foo command");
});
test("returns the newly-created subcommand", () => {
const program = new Command();
const cmd = registerCommand(program, {
name: "bar",
description: "Bar command",
});
expect(program.commands.find((c) => c.name() === "bar")).toBe(cmd);
});
test("invokes the configure callback with the new subcommand", () => {
const program = new Command();
let received: Command | undefined;
registerCommand(program, {
name: "baz",
description: "Baz command",
configure: (cmd) => {
received = cmd;
cmd.option("-x, --xtra <value>", "extra");
},
});
expect(received).toBeDefined();
expect(received!.name()).toBe("baz");
const baz = program.commands.find((c) => c.name() === "baz");
expect(baz?.options.find((o) => o.long === "--xtra")).toBeDefined();
});
test("omitting configure is allowed and does not throw", () => {
const program = new Command();
expect(() =>
registerCommand(program, { name: "qux", description: "Qux" }),
).not.toThrow();
expect(program.commands.find((c) => c.name() === "qux")).toBeDefined();
});
});

16
src/commands/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Command } from "commander";
export interface CommandConfig {
name: string;
description: string;
configure?: (cmd: Command) => void;
}
export function registerCommand(
program: Command,
config: CommandConfig,
): Command {
const cmd = program.command(config.name).description(config.description);
config.configure?.(cmd);
return cmd;
}

View File

@@ -1,3 +1,11 @@
// Run brain
import type { Command } from "commander";
import { registerCommand } from "@/commands";
export async function run() {}
export function register(program: Command): Command {
return registerCommand(program, {
name: "run",
description: "Run BrainBox",
}).action(run);
}