mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: add SQLite state store and query CLI (#510)
* feat: add SQLite state store and ECC status CLI * fix: replace better-sqlite3 with sql.js to eliminate native module CI failures better-sqlite3 requires native C++ compilation (node-gyp, prebuild-install) which fails in CI across npm/pnpm on all platforms: - npm ci: lock file out of sync with native transitive deps - pnpm: native bindings not found at runtime - Windows: native compilation fails entirely sql.js is a pure JavaScript/WASM SQLite implementation with zero native dependencies. The adapter in index.js wraps the sql.js API to match the better-sqlite3 interface used by migrations.js and queries.js. Key implementation detail: sql.js db.export() implicitly ends active transactions, so the adapter defers disk writes (saveToDisk) until after transaction commit via an inTransaction guard flag. createStateStore is now async (sql.js requires async WASM init). Updated status.js, sessions-cli.js, and tests accordingly.
This commit is contained in:
191
scripts/lib/state-store/index.js
Normal file
191
scripts/lib/state-store/index.js
Normal file
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const { applyMigrations, getAppliedMigrations } = require('./migrations');
|
||||
const { createQueryApi } = require('./queries');
|
||||
const { assertValidEntity, validateEntity } = require('./schema');
|
||||
|
||||
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
|
||||
|
||||
function resolveStateStorePath(options = {}) {
|
||||
if (options.dbPath) {
|
||||
if (options.dbPath === ':memory:') {
|
||||
return options.dbPath;
|
||||
}
|
||||
return path.resolve(options.dbPath);
|
||||
}
|
||||
|
||||
const homeDir = options.homeDir || process.env.HOME || os.homedir();
|
||||
return path.join(homeDir, DEFAULT_STATE_STORE_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a sql.js Database with a better-sqlite3-compatible API surface so
|
||||
* that the rest of the state-store code (migrations.js, queries.js) can
|
||||
* operate without knowing which driver is in use.
|
||||
*
|
||||
* IMPORTANT: sql.js db.export() implicitly ends any active transaction, so
|
||||
* we must defer all disk writes until after the transaction commits.
|
||||
*/
|
||||
function wrapSqlJsDatabase(rawDb, dbPath) {
|
||||
let inTransaction = false;
|
||||
|
||||
function saveToDisk() {
|
||||
if (dbPath === ':memory:' || inTransaction) {
|
||||
return;
|
||||
}
|
||||
const data = rawDb.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
|
||||
const db = {
|
||||
exec(sql) {
|
||||
rawDb.run(sql);
|
||||
saveToDisk();
|
||||
},
|
||||
|
||||
pragma(pragmaStr) {
|
||||
try {
|
||||
rawDb.run(`PRAGMA ${pragmaStr}`);
|
||||
} catch (_error) {
|
||||
// Ignore unsupported pragmas (e.g. WAL for in-memory databases).
|
||||
}
|
||||
},
|
||||
|
||||
prepare(sql) {
|
||||
return {
|
||||
all(...positionalArgs) {
|
||||
const stmt = rawDb.prepare(sql);
|
||||
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
|
||||
stmt.bind([positionalArgs[0]]);
|
||||
} else if (positionalArgs.length > 1) {
|
||||
stmt.bind(positionalArgs);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return rows;
|
||||
},
|
||||
|
||||
get(...positionalArgs) {
|
||||
const stmt = rawDb.prepare(sql);
|
||||
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
|
||||
stmt.bind([positionalArgs[0]]);
|
||||
} else if (positionalArgs.length > 1) {
|
||||
stmt.bind(positionalArgs);
|
||||
}
|
||||
|
||||
let row = null;
|
||||
if (stmt.step()) {
|
||||
row = stmt.getAsObject();
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
},
|
||||
|
||||
run(namedParams) {
|
||||
const stmt = rawDb.prepare(sql);
|
||||
if (namedParams && typeof namedParams === 'object' && !Array.isArray(namedParams)) {
|
||||
const sqlJsParams = {};
|
||||
for (const [key, value] of Object.entries(namedParams)) {
|
||||
sqlJsParams[`@${key}`] = value === undefined ? null : value;
|
||||
}
|
||||
stmt.bind(sqlJsParams);
|
||||
}
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
saveToDisk();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
transaction(fn) {
|
||||
return (...args) => {
|
||||
rawDb.run('BEGIN');
|
||||
inTransaction = true;
|
||||
try {
|
||||
const result = fn(...args);
|
||||
rawDb.run('COMMIT');
|
||||
inTransaction = false;
|
||||
saveToDisk();
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
rawDb.run('ROLLBACK');
|
||||
} catch (_rollbackError) {
|
||||
// Transaction may already be rolled back.
|
||||
}
|
||||
inTransaction = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
close() {
|
||||
saveToDisk();
|
||||
rawDb.close();
|
||||
},
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
async function openDatabase(SQL, dbPath) {
|
||||
if (dbPath !== ':memory:') {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
let rawDb;
|
||||
if (dbPath !== ':memory:' && fs.existsSync(dbPath)) {
|
||||
const fileBuffer = fs.readFileSync(dbPath);
|
||||
rawDb = new SQL.Database(fileBuffer);
|
||||
} else {
|
||||
rawDb = new SQL.Database();
|
||||
}
|
||||
|
||||
const db = wrapSqlJsDatabase(rawDb, dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
try {
|
||||
db.pragma('journal_mode = WAL');
|
||||
} catch (_error) {
|
||||
// Some SQLite environments reject WAL for in-memory or readonly contexts.
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
async function createStateStore(options = {}) {
|
||||
const dbPath = resolveStateStorePath(options);
|
||||
const SQL = await initSqlJs();
|
||||
const db = await openDatabase(SQL, dbPath);
|
||||
const appliedMigrations = applyMigrations(db);
|
||||
const queryApi = createQueryApi(db);
|
||||
|
||||
return {
|
||||
dbPath,
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
getAppliedMigrations() {
|
||||
return getAppliedMigrations(db);
|
||||
},
|
||||
validateEntity,
|
||||
assertValidEntity,
|
||||
...queryApi,
|
||||
_database: db,
|
||||
_migrations: appliedMigrations,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_STATE_STORE_RELATIVE_PATH,
|
||||
createStateStore,
|
||||
resolveStateStorePath,
|
||||
};
|
||||
178
scripts/lib/state-store/migrations.js
Normal file
178
scripts/lib/state-store/migrations.js
Normal file
@@ -0,0 +1,178 @@
|
||||
'use strict';
|
||||
|
||||
const INITIAL_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
adapter_id TEXT NOT NULL,
|
||||
harness TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
repo_root TEXT,
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
snapshot TEXT NOT NULL CHECK (json_valid(snapshot))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_state_started_at
|
||||
ON sessions (state, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started_at
|
||||
ON sessions (started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
skill_id TEXT NOT NULL,
|
||||
skill_version TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
task_description TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
failure_reason TEXT,
|
||||
tokens_used INTEGER,
|
||||
duration_ms INTEGER,
|
||||
user_feedback TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_runs_session_id_created_at
|
||||
ON skill_runs (session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_runs_created_at
|
||||
ON skill_runs (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_runs_outcome_created_at
|
||||
ON skill_runs (outcome, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_versions (
|
||||
skill_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
amendment_reason TEXT,
|
||||
promoted_at TEXT,
|
||||
rolled_back_at TEXT,
|
||||
PRIMARY KEY (skill_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_versions_promoted_at
|
||||
ON skill_versions (promoted_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
rationale TEXT NOT NULL,
|
||||
alternatives TEXT NOT NULL CHECK (json_valid(alternatives)),
|
||||
supersedes TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supersedes) REFERENCES decisions (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_session_id_created_at
|
||||
ON decisions (session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_status_created_at
|
||||
ON decisions (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS install_state (
|
||||
target_id TEXT NOT NULL,
|
||||
target_root TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
modules TEXT NOT NULL CHECK (json_valid(modules)),
|
||||
operations TEXT NOT NULL CHECK (json_valid(operations)),
|
||||
installed_at TEXT NOT NULL,
|
||||
source_version TEXT,
|
||||
PRIMARY KEY (target_id, target_root)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_install_state_installed_at
|
||||
ON install_state (installed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS governance_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL CHECK (json_valid(payload)),
|
||||
resolved_at TEXT,
|
||||
resolution TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_events_resolved_at_created_at
|
||||
ON governance_events (resolved_at, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_events_session_id_created_at
|
||||
ON governance_events (session_id, created_at DESC);
|
||||
`;
|
||||
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
version: 1,
|
||||
name: '001_initial_state_store',
|
||||
sql: INITIAL_SCHEMA_SQL,
|
||||
},
|
||||
];
|
||||
|
||||
function ensureMigrationTable(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function getAppliedMigrations(db) {
|
||||
ensureMigrationTable(db);
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT version, name, applied_at
|
||||
FROM schema_migrations
|
||||
ORDER BY version ASC
|
||||
`)
|
||||
.all()
|
||||
.map(row => ({
|
||||
version: row.version,
|
||||
name: row.name,
|
||||
appliedAt: row.applied_at,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyMigrations(db) {
|
||||
ensureMigrationTable(db);
|
||||
|
||||
const appliedVersions = new Set(
|
||||
db.prepare('SELECT version FROM schema_migrations').all().map(row => row.version)
|
||||
);
|
||||
const insertMigration = db.prepare(`
|
||||
INSERT INTO schema_migrations (version, name, applied_at)
|
||||
VALUES (@version, @name, @applied_at)
|
||||
`);
|
||||
|
||||
const applyPending = db.transaction(() => {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (appliedVersions.has(migration.version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.exec(migration.sql);
|
||||
insertMigration.run({
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
applied_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
applyPending();
|
||||
return getAppliedMigrations(db);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIGRATIONS,
|
||||
applyMigrations,
|
||||
getAppliedMigrations,
|
||||
};
|
||||
697
scripts/lib/state-store/queries.js
Normal file
697
scripts/lib/state-store/queries.js
Normal file
@@ -0,0 +1,697 @@
|
||||
'use strict';
|
||||
|
||||
const { assertValidEntity } = require('./schema');
|
||||
|
||||
const ACTIVE_SESSION_STATES = ['active', 'running', 'idle'];
|
||||
const SUCCESS_OUTCOMES = new Set(['success', 'succeeded', 'passed']);
|
||||
const FAILURE_OUTCOMES = new Set(['failure', 'failed', 'error']);
|
||||
|
||||
function normalizeLimit(value, fallback) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid limit: ${value}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsonColumn(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
function stringifyJson(value, label) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to serialize ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mapSessionRow(row) {
|
||||
const snapshot = parseJsonColumn(row.snapshot, {});
|
||||
return {
|
||||
id: row.id,
|
||||
adapterId: row.adapter_id,
|
||||
harness: row.harness,
|
||||
state: row.state,
|
||||
repoRoot: row.repo_root,
|
||||
startedAt: row.started_at,
|
||||
endedAt: row.ended_at,
|
||||
snapshot,
|
||||
workerCount: Array.isArray(snapshot && snapshot.workers) ? snapshot.workers.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkillRunRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
skillId: row.skill_id,
|
||||
skillVersion: row.skill_version,
|
||||
sessionId: row.session_id,
|
||||
taskDescription: row.task_description,
|
||||
outcome: row.outcome,
|
||||
failureReason: row.failure_reason,
|
||||
tokensUsed: row.tokens_used,
|
||||
durationMs: row.duration_ms,
|
||||
userFeedback: row.user_feedback,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkillVersionRow(row) {
|
||||
return {
|
||||
skillId: row.skill_id,
|
||||
version: row.version,
|
||||
contentHash: row.content_hash,
|
||||
amendmentReason: row.amendment_reason,
|
||||
promotedAt: row.promoted_at,
|
||||
rolledBackAt: row.rolled_back_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDecisionRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
title: row.title,
|
||||
rationale: row.rationale,
|
||||
alternatives: parseJsonColumn(row.alternatives, []),
|
||||
supersedes: row.supersedes,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapInstallStateRow(row) {
|
||||
const modules = parseJsonColumn(row.modules, []);
|
||||
const operations = parseJsonColumn(row.operations, []);
|
||||
const status = row.source_version && row.installed_at ? 'healthy' : 'warning';
|
||||
|
||||
return {
|
||||
targetId: row.target_id,
|
||||
targetRoot: row.target_root,
|
||||
profile: row.profile,
|
||||
modules,
|
||||
operations,
|
||||
installedAt: row.installed_at,
|
||||
sourceVersion: row.source_version,
|
||||
moduleCount: Array.isArray(modules) ? modules.length : 0,
|
||||
operationCount: Array.isArray(operations) ? operations.length : 0,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGovernanceEventRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
eventType: row.event_type,
|
||||
payload: parseJsonColumn(row.payload, null),
|
||||
resolvedAt: row.resolved_at,
|
||||
resolution: row.resolution,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function classifyOutcome(outcome) {
|
||||
const normalized = String(outcome || '').toLowerCase();
|
||||
if (SUCCESS_OUTCOMES.has(normalized)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (FAILURE_OUTCOMES.has(normalized)) {
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function toPercent(numerator, denominator) {
|
||||
if (denominator === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(((numerator / denominator) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
function summarizeSkillRuns(skillRuns) {
|
||||
const summary = {
|
||||
totalCount: skillRuns.length,
|
||||
knownCount: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
unknownCount: 0,
|
||||
successRate: null,
|
||||
failureRate: null,
|
||||
};
|
||||
|
||||
for (const skillRun of skillRuns) {
|
||||
const classification = classifyOutcome(skillRun.outcome);
|
||||
if (classification === 'success') {
|
||||
summary.successCount += 1;
|
||||
summary.knownCount += 1;
|
||||
} else if (classification === 'failure') {
|
||||
summary.failureCount += 1;
|
||||
summary.knownCount += 1;
|
||||
} else {
|
||||
summary.unknownCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary.successRate = toPercent(summary.successCount, summary.knownCount);
|
||||
summary.failureRate = toPercent(summary.failureCount, summary.knownCount);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizeInstallHealth(installations) {
|
||||
if (installations.length === 0) {
|
||||
return {
|
||||
status: 'missing',
|
||||
totalCount: 0,
|
||||
healthyCount: 0,
|
||||
warningCount: 0,
|
||||
installations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const summary = installations.reduce((result, installation) => {
|
||||
if (installation.status === 'healthy') {
|
||||
result.healthyCount += 1;
|
||||
} else {
|
||||
result.warningCount += 1;
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
totalCount: installations.length,
|
||||
healthyCount: 0,
|
||||
warningCount: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
status: summary.warningCount > 0 ? 'warning' : 'healthy',
|
||||
...summary,
|
||||
installations,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionInput(session) {
|
||||
return {
|
||||
id: session.id,
|
||||
adapterId: session.adapterId,
|
||||
harness: session.harness,
|
||||
state: session.state,
|
||||
repoRoot: session.repoRoot ?? null,
|
||||
startedAt: session.startedAt ?? null,
|
||||
endedAt: session.endedAt ?? null,
|
||||
snapshot: session.snapshot ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillRunInput(skillRun) {
|
||||
return {
|
||||
id: skillRun.id,
|
||||
skillId: skillRun.skillId,
|
||||
skillVersion: skillRun.skillVersion,
|
||||
sessionId: skillRun.sessionId,
|
||||
taskDescription: skillRun.taskDescription,
|
||||
outcome: skillRun.outcome,
|
||||
failureReason: skillRun.failureReason ?? null,
|
||||
tokensUsed: skillRun.tokensUsed ?? null,
|
||||
durationMs: skillRun.durationMs ?? null,
|
||||
userFeedback: skillRun.userFeedback ?? null,
|
||||
createdAt: skillRun.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillVersionInput(skillVersion) {
|
||||
return {
|
||||
skillId: skillVersion.skillId,
|
||||
version: skillVersion.version,
|
||||
contentHash: skillVersion.contentHash,
|
||||
amendmentReason: skillVersion.amendmentReason ?? null,
|
||||
promotedAt: skillVersion.promotedAt ?? null,
|
||||
rolledBackAt: skillVersion.rolledBackAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDecisionInput(decision) {
|
||||
return {
|
||||
id: decision.id,
|
||||
sessionId: decision.sessionId,
|
||||
title: decision.title,
|
||||
rationale: decision.rationale,
|
||||
alternatives: decision.alternatives === undefined || decision.alternatives === null
|
||||
? []
|
||||
: decision.alternatives,
|
||||
supersedes: decision.supersedes ?? null,
|
||||
status: decision.status,
|
||||
createdAt: decision.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInstallStateInput(installState) {
|
||||
return {
|
||||
targetId: installState.targetId,
|
||||
targetRoot: installState.targetRoot,
|
||||
profile: installState.profile ?? null,
|
||||
modules: installState.modules === undefined || installState.modules === null
|
||||
? []
|
||||
: installState.modules,
|
||||
operations: installState.operations === undefined || installState.operations === null
|
||||
? []
|
||||
: installState.operations,
|
||||
installedAt: installState.installedAt || new Date().toISOString(),
|
||||
sourceVersion: installState.sourceVersion ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGovernanceEventInput(governanceEvent) {
|
||||
return {
|
||||
id: governanceEvent.id,
|
||||
sessionId: governanceEvent.sessionId ?? null,
|
||||
eventType: governanceEvent.eventType,
|
||||
payload: governanceEvent.payload ?? null,
|
||||
resolvedAt: governanceEvent.resolvedAt ?? null,
|
||||
resolution: governanceEvent.resolution ?? null,
|
||||
createdAt: governanceEvent.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createQueryApi(db) {
|
||||
const listRecentSessionsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM sessions
|
||||
ORDER BY COALESCE(started_at, ended_at, '') DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const countSessionsStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM sessions
|
||||
`);
|
||||
const getSessionStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE id = ?
|
||||
`);
|
||||
const getSessionSkillRunsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM skill_runs
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
const getSessionDecisionsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM decisions
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
const listActiveSessionsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE ended_at IS NULL
|
||||
AND state IN ('active', 'running', 'idle')
|
||||
ORDER BY COALESCE(started_at, ended_at, '') DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const countActiveSessionsStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM sessions
|
||||
WHERE ended_at IS NULL
|
||||
AND state IN ('active', 'running', 'idle')
|
||||
`);
|
||||
const listRecentSkillRunsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM skill_runs
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const listInstallStateStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM install_state
|
||||
ORDER BY installed_at DESC, target_id ASC
|
||||
`);
|
||||
const countPendingGovernanceStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM governance_events
|
||||
WHERE resolved_at IS NULL
|
||||
`);
|
||||
const listPendingGovernanceStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM governance_events
|
||||
WHERE resolved_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const getSkillVersionStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM skill_versions
|
||||
WHERE skill_id = ? AND version = ?
|
||||
`);
|
||||
|
||||
const upsertSessionStatement = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
adapter_id,
|
||||
harness,
|
||||
state,
|
||||
repo_root,
|
||||
started_at,
|
||||
ended_at,
|
||||
snapshot
|
||||
) VALUES (
|
||||
@id,
|
||||
@adapter_id,
|
||||
@harness,
|
||||
@state,
|
||||
@repo_root,
|
||||
@started_at,
|
||||
@ended_at,
|
||||
@snapshot
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
adapter_id = excluded.adapter_id,
|
||||
harness = excluded.harness,
|
||||
state = excluded.state,
|
||||
repo_root = excluded.repo_root,
|
||||
started_at = excluded.started_at,
|
||||
ended_at = excluded.ended_at,
|
||||
snapshot = excluded.snapshot
|
||||
`);
|
||||
|
||||
const insertSkillRunStatement = db.prepare(`
|
||||
INSERT INTO skill_runs (
|
||||
id,
|
||||
skill_id,
|
||||
skill_version,
|
||||
session_id,
|
||||
task_description,
|
||||
outcome,
|
||||
failure_reason,
|
||||
tokens_used,
|
||||
duration_ms,
|
||||
user_feedback,
|
||||
created_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@skill_id,
|
||||
@skill_version,
|
||||
@session_id,
|
||||
@task_description,
|
||||
@outcome,
|
||||
@failure_reason,
|
||||
@tokens_used,
|
||||
@duration_ms,
|
||||
@user_feedback,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
skill_id = excluded.skill_id,
|
||||
skill_version = excluded.skill_version,
|
||||
session_id = excluded.session_id,
|
||||
task_description = excluded.task_description,
|
||||
outcome = excluded.outcome,
|
||||
failure_reason = excluded.failure_reason,
|
||||
tokens_used = excluded.tokens_used,
|
||||
duration_ms = excluded.duration_ms,
|
||||
user_feedback = excluded.user_feedback,
|
||||
created_at = excluded.created_at
|
||||
`);
|
||||
|
||||
const upsertSkillVersionStatement = db.prepare(`
|
||||
INSERT INTO skill_versions (
|
||||
skill_id,
|
||||
version,
|
||||
content_hash,
|
||||
amendment_reason,
|
||||
promoted_at,
|
||||
rolled_back_at
|
||||
) VALUES (
|
||||
@skill_id,
|
||||
@version,
|
||||
@content_hash,
|
||||
@amendment_reason,
|
||||
@promoted_at,
|
||||
@rolled_back_at
|
||||
)
|
||||
ON CONFLICT(skill_id, version) DO UPDATE SET
|
||||
content_hash = excluded.content_hash,
|
||||
amendment_reason = excluded.amendment_reason,
|
||||
promoted_at = excluded.promoted_at,
|
||||
rolled_back_at = excluded.rolled_back_at
|
||||
`);
|
||||
|
||||
const insertDecisionStatement = db.prepare(`
|
||||
INSERT INTO decisions (
|
||||
id,
|
||||
session_id,
|
||||
title,
|
||||
rationale,
|
||||
alternatives,
|
||||
supersedes,
|
||||
status,
|
||||
created_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@session_id,
|
||||
@title,
|
||||
@rationale,
|
||||
@alternatives,
|
||||
@supersedes,
|
||||
@status,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
title = excluded.title,
|
||||
rationale = excluded.rationale,
|
||||
alternatives = excluded.alternatives,
|
||||
supersedes = excluded.supersedes,
|
||||
status = excluded.status,
|
||||
created_at = excluded.created_at
|
||||
`);
|
||||
|
||||
const upsertInstallStateStatement = db.prepare(`
|
||||
INSERT INTO install_state (
|
||||
target_id,
|
||||
target_root,
|
||||
profile,
|
||||
modules,
|
||||
operations,
|
||||
installed_at,
|
||||
source_version
|
||||
) VALUES (
|
||||
@target_id,
|
||||
@target_root,
|
||||
@profile,
|
||||
@modules,
|
||||
@operations,
|
||||
@installed_at,
|
||||
@source_version
|
||||
)
|
||||
ON CONFLICT(target_id, target_root) DO UPDATE SET
|
||||
profile = excluded.profile,
|
||||
modules = excluded.modules,
|
||||
operations = excluded.operations,
|
||||
installed_at = excluded.installed_at,
|
||||
source_version = excluded.source_version
|
||||
`);
|
||||
|
||||
const insertGovernanceEventStatement = db.prepare(`
|
||||
INSERT INTO governance_events (
|
||||
id,
|
||||
session_id,
|
||||
event_type,
|
||||
payload,
|
||||
resolved_at,
|
||||
resolution,
|
||||
created_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@session_id,
|
||||
@event_type,
|
||||
@payload,
|
||||
@resolved_at,
|
||||
@resolution,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
event_type = excluded.event_type,
|
||||
payload = excluded.payload,
|
||||
resolved_at = excluded.resolved_at,
|
||||
resolution = excluded.resolution,
|
||||
created_at = excluded.created_at
|
||||
`);
|
||||
|
||||
function getSessionById(id) {
|
||||
const row = getSessionStatement.get(id);
|
||||
return row ? mapSessionRow(row) : null;
|
||||
}
|
||||
|
||||
function listRecentSessions(options = {}) {
|
||||
const limit = normalizeLimit(options.limit, 10);
|
||||
return {
|
||||
totalCount: countSessionsStatement.get().total_count,
|
||||
sessions: listRecentSessionsStatement.all(limit).map(mapSessionRow),
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionDetail(id) {
|
||||
const session = getSessionById(id);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workers = Array.isArray(session.snapshot && session.snapshot.workers)
|
||||
? session.snapshot.workers.map(worker => ({ ...worker }))
|
||||
: [];
|
||||
|
||||
return {
|
||||
session,
|
||||
workers,
|
||||
skillRuns: getSessionSkillRunsStatement.all(id).map(mapSkillRunRow),
|
||||
decisions: getSessionDecisionsStatement.all(id).map(mapDecisionRow),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatus(options = {}) {
|
||||
const activeLimit = normalizeLimit(options.activeLimit, 5);
|
||||
const recentSkillRunLimit = normalizeLimit(options.recentSkillRunLimit, 20);
|
||||
const pendingLimit = normalizeLimit(options.pendingLimit, 5);
|
||||
|
||||
const activeSessions = listActiveSessionsStatement.all(activeLimit).map(mapSessionRow);
|
||||
const recentSkillRuns = listRecentSkillRunsStatement.all(recentSkillRunLimit).map(mapSkillRunRow);
|
||||
const installations = listInstallStateStatement.all().map(mapInstallStateRow);
|
||||
const pendingGovernanceEvents = listPendingGovernanceStatement.all(pendingLimit).map(mapGovernanceEventRow);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: {
|
||||
activeCount: countActiveSessionsStatement.get().total_count,
|
||||
sessions: activeSessions,
|
||||
},
|
||||
skillRuns: {
|
||||
windowSize: recentSkillRunLimit,
|
||||
summary: summarizeSkillRuns(recentSkillRuns),
|
||||
recent: recentSkillRuns,
|
||||
},
|
||||
installHealth: summarizeInstallHealth(installations),
|
||||
governance: {
|
||||
pendingCount: countPendingGovernanceStatement.get().total_count,
|
||||
events: pendingGovernanceEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getSessionById,
|
||||
getSessionDetail,
|
||||
getStatus,
|
||||
insertDecision(decision) {
|
||||
const normalized = normalizeDecisionInput(decision);
|
||||
assertValidEntity('decision', normalized);
|
||||
insertDecisionStatement.run({
|
||||
id: normalized.id,
|
||||
session_id: normalized.sessionId,
|
||||
title: normalized.title,
|
||||
rationale: normalized.rationale,
|
||||
alternatives: stringifyJson(normalized.alternatives, 'decision.alternatives'),
|
||||
supersedes: normalized.supersedes,
|
||||
status: normalized.status,
|
||||
created_at: normalized.createdAt,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
insertGovernanceEvent(governanceEvent) {
|
||||
const normalized = normalizeGovernanceEventInput(governanceEvent);
|
||||
assertValidEntity('governanceEvent', normalized);
|
||||
insertGovernanceEventStatement.run({
|
||||
id: normalized.id,
|
||||
session_id: normalized.sessionId,
|
||||
event_type: normalized.eventType,
|
||||
payload: stringifyJson(normalized.payload, 'governanceEvent.payload'),
|
||||
resolved_at: normalized.resolvedAt,
|
||||
resolution: normalized.resolution,
|
||||
created_at: normalized.createdAt,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
insertSkillRun(skillRun) {
|
||||
const normalized = normalizeSkillRunInput(skillRun);
|
||||
assertValidEntity('skillRun', normalized);
|
||||
insertSkillRunStatement.run({
|
||||
id: normalized.id,
|
||||
skill_id: normalized.skillId,
|
||||
skill_version: normalized.skillVersion,
|
||||
session_id: normalized.sessionId,
|
||||
task_description: normalized.taskDescription,
|
||||
outcome: normalized.outcome,
|
||||
failure_reason: normalized.failureReason,
|
||||
tokens_used: normalized.tokensUsed,
|
||||
duration_ms: normalized.durationMs,
|
||||
user_feedback: normalized.userFeedback,
|
||||
created_at: normalized.createdAt,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
listRecentSessions,
|
||||
upsertInstallState(installState) {
|
||||
const normalized = normalizeInstallStateInput(installState);
|
||||
assertValidEntity('installState', normalized);
|
||||
upsertInstallStateStatement.run({
|
||||
target_id: normalized.targetId,
|
||||
target_root: normalized.targetRoot,
|
||||
profile: normalized.profile,
|
||||
modules: stringifyJson(normalized.modules, 'installState.modules'),
|
||||
operations: stringifyJson(normalized.operations, 'installState.operations'),
|
||||
installed_at: normalized.installedAt,
|
||||
source_version: normalized.sourceVersion,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
upsertSession(session) {
|
||||
const normalized = normalizeSessionInput(session);
|
||||
assertValidEntity('session', normalized);
|
||||
upsertSessionStatement.run({
|
||||
id: normalized.id,
|
||||
adapter_id: normalized.adapterId,
|
||||
harness: normalized.harness,
|
||||
state: normalized.state,
|
||||
repo_root: normalized.repoRoot,
|
||||
started_at: normalized.startedAt,
|
||||
ended_at: normalized.endedAt,
|
||||
snapshot: stringifyJson(normalized.snapshot, 'session.snapshot'),
|
||||
});
|
||||
return getSessionById(normalized.id);
|
||||
},
|
||||
upsertSkillVersion(skillVersion) {
|
||||
const normalized = normalizeSkillVersionInput(skillVersion);
|
||||
assertValidEntity('skillVersion', normalized);
|
||||
upsertSkillVersionStatement.run({
|
||||
skill_id: normalized.skillId,
|
||||
version: normalized.version,
|
||||
content_hash: normalized.contentHash,
|
||||
amendment_reason: normalized.amendmentReason,
|
||||
promoted_at: normalized.promotedAt,
|
||||
rolled_back_at: normalized.rolledBackAt,
|
||||
});
|
||||
const row = getSkillVersionStatement.get(normalized.skillId, normalized.version);
|
||||
return row ? mapSkillVersionRow(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTIVE_SESSION_STATES,
|
||||
FAILURE_OUTCOMES,
|
||||
SUCCESS_OUTCOMES,
|
||||
createQueryApi,
|
||||
};
|
||||
92
scripts/lib/state-store/schema.js
Normal file
92
scripts/lib/state-store/schema.js
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'state-store.schema.json');
|
||||
|
||||
const ENTITY_DEFINITIONS = {
|
||||
session: 'session',
|
||||
skillRun: 'skillRun',
|
||||
skillVersion: 'skillVersion',
|
||||
decision: 'decision',
|
||||
installState: 'installState',
|
||||
governanceEvent: 'governanceEvent',
|
||||
};
|
||||
|
||||
let cachedSchema = null;
|
||||
let cachedAjv = null;
|
||||
const cachedValidators = new Map();
|
||||
|
||||
function readSchema() {
|
||||
if (cachedSchema) {
|
||||
return cachedSchema;
|
||||
}
|
||||
|
||||
cachedSchema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
|
||||
return cachedSchema;
|
||||
}
|
||||
|
||||
function getAjv() {
|
||||
if (cachedAjv) {
|
||||
return cachedAjv;
|
||||
}
|
||||
|
||||
cachedAjv = new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
});
|
||||
return cachedAjv;
|
||||
}
|
||||
|
||||
function getEntityValidator(entityName) {
|
||||
if (cachedValidators.has(entityName)) {
|
||||
return cachedValidators.get(entityName);
|
||||
}
|
||||
|
||||
const schema = readSchema();
|
||||
const definitionName = ENTITY_DEFINITIONS[entityName];
|
||||
|
||||
if (!definitionName || !schema.$defs || !schema.$defs[definitionName]) {
|
||||
throw new Error(`Unknown state-store schema entity: ${entityName}`);
|
||||
}
|
||||
|
||||
const validatorSchema = {
|
||||
$schema: schema.$schema,
|
||||
...schema.$defs[definitionName],
|
||||
$defs: schema.$defs,
|
||||
};
|
||||
const validator = getAjv().compile(validatorSchema);
|
||||
cachedValidators.set(entityName, validator);
|
||||
return validator;
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors
|
||||
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function validateEntity(entityName, payload) {
|
||||
const validator = getEntityValidator(entityName);
|
||||
const valid = validator(payload);
|
||||
return {
|
||||
valid,
|
||||
errors: validator.errors || [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidEntity(entityName, payload, label) {
|
||||
const result = validateEntity(entityName, payload);
|
||||
if (!result.valid) {
|
||||
throw new Error(`Invalid ${entityName}${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertValidEntity,
|
||||
formatValidationErrors,
|
||||
readSchema,
|
||||
validateEntity,
|
||||
};
|
||||
Reference in New Issue
Block a user