Files
Claude-Code-Monitor/server/lib/data-transfer.js
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

256 lines
9.8 KiB
JavaScript

/**
* @file server/lib/data-transfer.js
* @description Full-dataset export/import ("backup / restore") for the local
* dashboard database. This is the round-trip counterpart to the transcript
* importer (scripts/import-history.js): where that reconstructs sessions from
* raw Claude Code JSONL, this serializes the dashboard's OWN captured data to a
* single portable JSON bundle and restores it later — the workflow a user needs
* to consolidate several machines into one dashboard.
*
* Design guarantees:
* • Complete — the bundle carries every table that holds user-owned captured
* data or portable configuration: sessions, agents, events, token_usage,
* workflows, dashboard_runs, alert_rules, and model_pricing. Machine-bound
* or secret-bearing tables (push_subscriptions, webhook_targets/deliveries,
* alert_events audit log) are intentionally excluded.
* • Idempotent + non-destructive — restore is session-atomic: a session that
* already exists (matched by its stable UUID) is skipped WHOLE, together
* with its agents/events/token_usage/workflows, so re-importing the same
* bundle (or overlapping bundles from two machines) never duplicates rows
* or clobbers live data. Independent config rows (dashboard_runs,
* alert_rules, model_pricing) are inserted with INSERT OR IGNORE on their
* natural primary key.
* • Accurate — token_usage (including compaction baselines) is restored
* verbatim for every new session, so cost/analytics match the source
* machine exactly. events are re-inserted WITHOUT their source autoincrement
* id (which is not portable across databases); SQLite assigns fresh ids.
* • Schema-tolerant — inserts are built by intersecting each table's live
* columns (PRAGMA table_info) with the keys present on each row, so older
* or newer bundles import cleanly without a migration step.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const EXPORT_FORMAT = "ccam-export";
const EXPORT_VERSION = 1;
// Tables serialized into the bundle. Order matters for restore (parents before
// children); FK checks are deferred to COMMIT anyway (see importExportBundle).
const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"];
/**
* Build the full export bundle from the live database.
*
* @param {import("better-sqlite3").Database} db
* @param {{ listPricing: { all: () => any[] } }} stmts - the prepared-statement
* bag from server/db.js (used for the canonical model_pricing ordering).
* @returns {object} A JSON-serializable bundle stamped with format/version.
*/
function buildExportBundle(db, stmts) {
return {
format: EXPORT_FORMAT,
version: EXPORT_VERSION,
exported_at: new Date().toISOString(),
sessions: db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all(),
agents: db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all(),
events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(),
token_usage: db.prepare("SELECT * FROM token_usage").all(),
workflows: db.prepare("SELECT * FROM workflows").all(),
dashboard_runs: db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all(),
alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(),
model_pricing: stmts.listPricing.all(),
};
}
/** Column names of a table, in definition order. */
function tableColumns(db, table) {
return db
.prepare(`PRAGMA table_info(${table})`)
.all()
.map((c) => c.name);
}
// better-sqlite3 only binds numbers/strings/bigints/buffers/null. A row parsed
// from JSON never contains booleans/objects for these tables (SQLite stores
// them as INTEGER/TEXT), but a missing key yields `undefined`, which throws —
// normalize it to null so partial/legacy rows still bind.
function bindable(v) {
if (v === undefined) return null;
if (typeof v === "boolean") return v ? 1 : 0;
return v;
}
/**
* Make a prepared INSERT OR IGNORE that only writes the columns a table
* actually has AND the row actually provides. `omit` drops columns even if
* present (used to strip the non-portable events.id).
*/
function makeInserter(db, table, { omit = [] } = {}) {
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
const quoted = cols.map((c) => `"${c}"`).join(", ");
const placeholders = cols.map(() => "?").join(", ");
const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`);
return (row) => stmt.run(cols.map((c) => bindable(row[c])));
}
/** Group an array of rows by a key field into a Map. */
function groupBy(rows, key) {
const map = new Map();
for (const r of Array.isArray(rows) ? rows : []) {
if (!r || r[key] == null) continue;
const k = r[key];
if (!map.has(k)) map.set(k, []);
map.get(k).push(r);
}
return map;
}
class ImportFormatError extends Error {
constructor(message) {
super(message);
this.name = "ImportFormatError";
this.code = "INVALID_FORMAT";
}
}
/**
* Validate a parsed object looks like an export bundle. Accepts bundles stamped
* with our format marker AND legacy exports (pre-versioning) that merely carry
* a `sessions` array, so old backups remain importable.
*
* @throws {ImportFormatError}
*/
function assertBundle(bundle) {
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new ImportFormatError("Not a valid export file (expected a JSON object).");
}
if (bundle.format && bundle.format !== EXPORT_FORMAT) {
throw new ImportFormatError(
`Unrecognized export format "${bundle.format}" (expected "${EXPORT_FORMAT}").`
);
}
const hasAnyTable =
Array.isArray(bundle.sessions) ||
Array.isArray(bundle.model_pricing) ||
Array.isArray(bundle.alert_rules) ||
Array.isArray(bundle.dashboard_runs);
if (!bundle.format && !hasAnyTable) {
throw new ImportFormatError(
"Not a recognizable dashboard export (no sessions/pricing/rules arrays)."
);
}
}
/**
* Restore an export bundle into the live database. Idempotent and
* non-destructive (see file header). Runs inside a single transaction with
* deferred FK checks so agent parent/child ordering never trips a constraint.
*
* @param {import("better-sqlite3").Database} db
* @param {object} bundle - parsed export JSON.
* @returns {{sessions_imported:number, sessions_skipped:number, agents:number,
* events:number, token_usage:number, workflows:number, dashboard_runs:number,
* alert_rules:number, model_pricing:number, errors:number}}
*/
function importExportBundle(db, bundle) {
assertBundle(bundle);
const counters = {
sessions_imported: 0,
sessions_skipped: 0,
agents: 0,
events: 0,
token_usage: 0,
workflows: 0,
dashboard_runs: 0,
alert_rules: 0,
model_pricing: 0,
errors: 0,
};
const sessionExists = db.prepare("SELECT 1 FROM sessions WHERE id = ?").pluck();
const insert = {
sessions: makeInserter(db, "sessions"),
agents: makeInserter(db, "agents"),
events: makeInserter(db, "events", { omit: ["id"] }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs"),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
const childRows = {
agents: groupBy(bundle.agents, "session_id"),
events: groupBy(bundle.events, "session_id"),
token_usage: groupBy(bundle.token_usage, "session_id"),
workflows: groupBy(bundle.workflows, "session_id"),
};
const sessions = Array.isArray(bundle.sessions) ? bundle.sessions : [];
const run = db.transaction(() => {
// Defer FK enforcement to COMMIT: an agent's parent_agent_id may point to a
// sibling that is inserted later in the same batch. Auto-resets at COMMIT.
db.pragma("defer_foreign_keys = ON");
for (const s of sessions) {
if (!s || !s.id) {
counters.errors++;
continue;
}
if (sessionExists.get(s.id)) {
counters.sessions_skipped++;
continue;
}
insert.sessions(s);
counters.sessions_imported++;
// Agents first so events/token_usage that reference them satisfy FKs.
for (const a of childRows.agents.get(s.id) || []) {
if (insert.agents(a).changes > 0) counters.agents++;
}
// Insert events oldest-first so fresh autoincrement ids stay chronological.
const evs = (childRows.events.get(s.id) || [])
.slice()
.sort((a, b) => String(a.created_at || "").localeCompare(String(b.created_at || "")));
for (const e of evs) {
if (insert.events(e).changes > 0) counters.events++;
}
for (const tu of childRows.token_usage.get(s.id) || []) {
if (insert.token_usage(tu).changes > 0) counters.token_usage++;
}
for (const wf of childRows.workflows.get(s.id) || []) {
if (insert.workflows(wf).changes > 0) counters.workflows++;
}
}
// Session-independent, config-like tables: restore by natural PK, never
// overwriting a row the target machine already has.
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
}
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
}
for (const p of Array.isArray(bundle.model_pricing) ? bundle.model_pricing : []) {
if (p && p.model_pattern && insert.model_pricing(p).changes > 0) counters.model_pricing++;
}
});
run();
return counters;
}
module.exports = {
EXPORT_FORMAT,
EXPORT_VERSION,
SESSION_CHILD_TABLES,
buildExportBundle,
importExportBundle,
ImportFormatError,
};