summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 15:44:00 +0900
committerAdam Malczewski <[email protected]>2026-05-21 15:44:00 +0900
commit1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (patch)
tree57aec6c0d039760aa37fab10e83e1cea7a23081e /packages/api/src
parentc957e89e3ec46f1db64dcb1416f5ade7fb6e617e (diff)
downloaddispatch-1f309ccca20aabbd0ee3fb8fbb3c8192124edd95.tar.gz
dispatch-1f309ccca20aabbd0ee3fb8fbb3c8192124edd95.zip
fix: wake scheduler persistence/retry, credential filtering, usage cache and display names
- Wake scheduler: fix Bun timer leak, make recurring daily, persist to disk, retry failed wakes every 5min for 30min, start at boot - Key usage: localStorage cache survives page refresh, spinner during all refreshes, show cached data immediately - Credential filtering: key-usage and wake only use configured credentials_file, exclude unconfigured accounts - Display: remove counter suffix from Claude labels, format opencode/copilot key names
Diffstat (limited to 'packages/api/src')
-rw-r--r--packages/api/src/app.ts5
-rw-r--r--packages/api/src/routes/models.ts178
2 files changed, 161 insertions, 22 deletions
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 3591281..ce26aaa 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -4,7 +4,7 @@ import { AgentManager } from "./agent-manager.js";
import { PermissionManager } from "./permission-manager.js";
import { configRoutes } from "./routes/config.js";
import { skillsRoutes } from "./routes/skills.js";
-import { modelsRoutes } from "./routes/models.js";
+import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
export const permissionManager = new PermissionManager();
export const agentManager = new AgentManager(permissionManager);
@@ -60,3 +60,6 @@ app.post("/chat", async (c) => {
app.route("/config", configRoutes);
app.route("/skills", skillsRoutes);
app.route("/models", modelsRoutes);
+
+// Start the wake scheduler on boot (restores persisted schedule)
+startWakeScheduler();
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index f29d236..2f11268 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -1,3 +1,5 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
import type { ModelRegistry, ModelResolver } from "@dispatch/core";
import {
ANTHROPIC_MODELS_FALLBACK,
@@ -259,11 +261,16 @@ modelsRoutes.get("/key-usage", async (c) => {
try {
if (provider === "anthropic") {
- const accounts = discoverClaudeAccounts();
+ const allAccounts = discoverClaudeAccounts();
+ const credFile = key.definition.credentials_file;
+ // Only show the account matching this key's credentials_file
+ const accounts = credFile
+ ? allAccounts.filter((a) => a.source === credFile)
+ : allAccounts.slice(0, 1); // no credentials_file → default account only
if (accounts.length === 0) {
return c.json({ error: "no Claude accounts available" }, 502);
}
- // Fetch usage for all discovered accounts
+ // Fetch usage for matched accounts
const accountResults = await Promise.all(
accounts.map(async (acct) => {
const report = await getAccountUsage(acct);
@@ -341,7 +348,25 @@ modelsRoutes.get("/key-usage", async (c) => {
async function wakeAllClaudeAccounts(): Promise<
Array<{ label: string; ok: boolean; error?: string }>
> {
- const accounts = discoverClaudeAccounts();
+ // Only wake accounts referenced by configured anthropic keys
+ const allAccounts = discoverClaudeAccounts();
+ const registry = getRegistry();
+ const configuredCredFiles = new Set<string>();
+ if (registry) {
+ for (const ks of registry.getKeys()) {
+ if (ks.definition.provider === "anthropic") {
+ if (ks.definition.credentials_file) {
+ configuredCredFiles.add(ks.definition.credentials_file);
+ } else if (allAccounts[0]) {
+ // Key without explicit credentials_file uses default account
+ configuredCredFiles.add(allAccounts[0].source);
+ }
+ }
+ }
+ }
+ const accounts = configuredCredFiles.size > 0
+ ? allAccounts.filter((a) => configuredCredFiles.has(a.source))
+ : allAccounts;
if (accounts.length === 0) {
return [{ label: "(none)", ok: false, error: "no Claude accounts available" }];
}
@@ -389,37 +414,147 @@ modelsRoutes.post("/wake", async (c) => {
// ─── Wake scheduler (runs on backend, survives frontend close) ─
-type WakeSchedule = Record<number, number>; // hour → target timestamp (ms)
+type WakeSchedule = Record<number, number>; // hour → next wake timestamp (ms)
-let wakeSchedule: WakeSchedule = {};
+interface PendingRetry {
+ retriesLeft: number; // starts at 6 (5 min × 6 = 30 min)
+ nextRetryAt: number; // timestamp for next retry attempt
+}
+
+const SCHEDULE_FILE = join(process.cwd(), ".wake-schedule.json");
+
+function nextOccurrenceAt15(hour: number): number {
+ const now = new Date();
+ const target = new Date(now);
+ target.setHours(hour, 15, 0, 0);
+ if (target.getTime() <= Date.now()) {
+ target.setDate(target.getDate() + 1);
+ }
+ return target.getTime();
+}
+
+function loadScheduleFromDisk(): WakeSchedule {
+ try {
+ if (existsSync(SCHEDULE_FILE)) {
+ const raw = readFileSync(SCHEDULE_FILE, "utf-8");
+ const parsed = JSON.parse(raw) as Record<string, number>;
+ const schedule: WakeSchedule = {};
+ let needsPersist = false;
+ for (const [key, value] of Object.entries(parsed)) {
+ const hour = Number(key);
+ if (value > Date.now()) {
+ schedule[hour] = value;
+ } else {
+ // Timestamp has passed — recompute for next occurrence
+ schedule[hour] = nextOccurrenceAt15(hour);
+ needsPersist = true;
+ }
+ }
+ if (needsPersist) {
+ try {
+ writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule), "utf-8");
+ } catch {
+ // Ignore write errors
+ }
+ }
+ return schedule;
+ }
+ } catch {
+ // File doesn't exist or is corrupt — start fresh
+ }
+ return {};
+}
+
+function persistSchedule(): void {
+ try {
+ writeFileSync(SCHEDULE_FILE, JSON.stringify(wakeSchedule), "utf-8");
+ } catch {
+ // Ignore write errors
+ }
+}
+
+let wakeSchedule: WakeSchedule = loadScheduleFromDisk();
+let pendingRetries: PendingRetry[] = [];
// HMR-safe: clear previous tick before starting a new one
(globalThis as Record<string, unknown>)._dispatchWakeTimer ??= undefined;
const timerKey = "_dispatchWakeTimer";
+let isTickRunning = false;
async function schedulerTick(): Promise<void> {
- const now = Date.now();
- const hours = Object.keys(wakeSchedule).map(Number);
-
- for (const hour of hours) {
- const ts = wakeSchedule[hour];
- if (ts !== undefined && ts <= now) {
- // Delete BEFORE wake to prevent duplicate triggers
- delete wakeSchedule[hour];
- wakeAllClaudeAccounts().catch(() => {});
+ // Prevent concurrent tick execution (e.g. toggle called mid-tick)
+ if (isTickRunning) return;
+ isTickRunning = true;
+
+ try {
+ const now = Date.now();
+ const hours = Object.keys(wakeSchedule).map(Number);
+
+ for (const hour of hours) {
+ const ts = wakeSchedule[hour];
+ if (ts !== undefined && ts <= now) {
+ // Reschedule for next day (recurring daily)
+ wakeSchedule[hour] = nextOccurrenceAt15(hour);
+ persistSchedule();
+
+ // Wake accounts and track failures for retry
+ try {
+ const results = await wakeAllClaudeAccounts();
+ const anyFailed = results.some((r) => !r.ok);
+ if (anyFailed) {
+ pendingRetries.push({
+ retriesLeft: 6,
+ nextRetryAt: now + 5 * 60 * 1000,
+ });
+ }
+ } catch {
+ // Total failure — schedule retry
+ pendingRetries.push({
+ retriesLeft: 6,
+ nextRetryAt: now + 5 * 60 * 1000,
+ });
+ }
+ }
}
- }
- // Schedule next tick
- if (Object.keys(wakeSchedule).length > 0) {
- (globalThis as Record<string, unknown>)[timerKey] = setTimeout(schedulerTick, 30_000);
+ // Process pending retries (iterate backwards for safe splicing)
+ for (let i = pendingRetries.length - 1; i >= 0; i--) {
+ const retry = pendingRetries[i];
+ if (!retry || retry.nextRetryAt > now) continue;
+
+ try {
+ const results = await wakeAllClaudeAccounts();
+ const anyFailed = results.some((r) => !r.ok);
+ if (!anyFailed || retry.retriesLeft <= 1) {
+ // All succeeded or out of retries — remove
+ pendingRetries.splice(i, 1);
+ } else {
+ retry.retriesLeft--;
+ retry.nextRetryAt = now + 5 * 60 * 1000;
+ }
+ } catch {
+ if (retry.retriesLeft <= 1) {
+ pendingRetries.splice(i, 1);
+ } else {
+ retry.retriesLeft--;
+ retry.nextRetryAt = now + 5 * 60 * 1000;
+ }
+ }
+ }
+
+ // Schedule next tick while there's work to monitor
+ if (Object.keys(wakeSchedule).length > 0 || pendingRetries.length > 0) {
+ (globalThis as Record<string, unknown>)[timerKey] = setTimeout(schedulerTick, 30_000);
+ }
+ } finally {
+ isTickRunning = false;
}
}
export function startWakeScheduler(): void {
- // Clear any previous interval (HMR-safe)
+ // Clear any previous timer (HMR-safe — works with Bun's Timer objects)
const prev = (globalThis as Record<string, unknown>)[timerKey];
- if (typeof prev === "number") clearTimeout(prev);
+ if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>);
schedulerTick();
}
@@ -442,7 +577,8 @@ modelsRoutes.post("/wake-schedule/toggle", async (c) => {
wakeSchedule[hour] = ts;
}
- // Restart the tick loop (handles empty → non-empty or vice versa)
+ // Persist and restart the tick loop
+ persistSchedule();
startWakeScheduler();
return c.json({ schedule: wakeSchedule });