Files
Affaan Mustafa bae1129209 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.
2026-03-16 01:32:21 -07:00

698 lines
19 KiB
JavaScript

'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,
};