feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.
Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.
Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.
Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.
DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
+62
-8
@@ -76,15 +76,29 @@ const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
|
||||
const LOG_TAIL_DEFAULT = 64 * 1024;
|
||||
const LOG_TAIL_MAX = 1024 * 1024;
|
||||
|
||||
/** Seconds since this lane's session last emitted an event; null if never. */
|
||||
/**
|
||||
* Seconds since this lane last showed life: its session's newest event or the
|
||||
* start of its current run, whichever is newer; null if neither exists. The
|
||||
* run counts because a new run keeps the PREVIOUS session_id until the new
|
||||
* session's first hook rebinds it — judged by that old session alone, a run
|
||||
* started a second ago read "dead" and, with no lane field changing after
|
||||
* (a resume, or a hook ingested by another server on the same DB), stayed so
|
||||
* until a refresh. Only with a session bound: a lane no hook ever gives one
|
||||
* (a catalog tool, Codex without its hooks) has no signal, so stays null.
|
||||
*/
|
||||
const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?");
|
||||
const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?");
|
||||
function lastEventAge(lane) {
|
||||
if (!lane.session_id) return null;
|
||||
const row = db
|
||||
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
|
||||
.get(lane.session_id);
|
||||
if (!row || !row.last) return null;
|
||||
const t = Date.parse(row.last);
|
||||
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
const times = [];
|
||||
const row = lastEventStmt.get(lane.session_id);
|
||||
if (row?.last) times.push(Date.parse(row.last));
|
||||
if (lane.run_id) {
|
||||
const run = runStartStmt.get(lane.run_id);
|
||||
if (run?.started_at) times.push(Date.parse(run.started_at));
|
||||
}
|
||||
const t = Math.max(...times.filter((x) => !Number.isNaN(x)));
|
||||
return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +130,43 @@ function healAllRunIds() {
|
||||
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one
|
||||
* database, and hooks reach only one of them (the lowest port), so the other's
|
||||
* browsers never heard about a lane the hooks moved until they refreshed.
|
||||
* `PRAGMA data_version` moves only on another connection's commit, so the
|
||||
* common pass is one pragma; after one, each lane whose `updated_at` differs
|
||||
* from what this pass saw last is broadcast (a lane this server wrote itself
|
||||
* may go out twice — the client drops an identical copy), and a vanished
|
||||
* lane goes out as `removed`.
|
||||
* ponytail: two writes in the same millisecond share an updated_at; a counter
|
||||
* column would close that.
|
||||
*/
|
||||
let seenDataVersion = null;
|
||||
const seenLanes = new Map();
|
||||
const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes");
|
||||
function broadcastForeignLaneChanges() {
|
||||
const out = { changed: [], removed: [] };
|
||||
const version = db.pragma("data_version", { simple: true });
|
||||
if (version === seenDataVersion) return out;
|
||||
const primed = seenDataVersion !== null;
|
||||
seenDataVersion = version;
|
||||
const live = new Set();
|
||||
for (const { id, updated_at } of laneStampsStmt.all()) {
|
||||
live.add(id);
|
||||
if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id);
|
||||
seenLanes.set(id, updated_at);
|
||||
}
|
||||
for (const id of [...seenLanes.keys()]) {
|
||||
if (live.has(id)) continue;
|
||||
seenLanes.delete(id);
|
||||
if (primed) out.removed.push(id);
|
||||
}
|
||||
for (const id of out.changed) broadcastLane(id);
|
||||
for (const id of out.removed) broadcast("lane_update", { removed: id });
|
||||
return out;
|
||||
}
|
||||
|
||||
function payload(lane) {
|
||||
const healed = healRunId(lane);
|
||||
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
||||
@@ -1774,7 +1825,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (err.code === "ETMUXMISSING") {
|
||||
return res.status(503).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
// A refused field (EBADMODEL, EBADEFFORT, …) is the request's fault.
|
||||
const status = /^EBAD/.test(err.code || "") ? 400 : 500;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
broadcastLane(lane.id);
|
||||
@@ -1794,3 +1847,4 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
module.exports = router;
|
||||
module.exports.broadcastLane = broadcastLane;
|
||||
module.exports.healAllRunIds = healAllRunIds;
|
||||
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;
|
||||
|
||||
Reference in New Issue
Block a user