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:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sql.js": "^1.14.1"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ecc": "scripts/ecc.js",
|
"ecc": "scripts/ecc.js",
|
||||||
"ecc-install": "install.sh"
|
"ecc-install": "install.sh"
|
||||||
@@ -2596,6 +2599,12 @@
|
|||||||
"url": "https://github.com/sponsors/cyyynthia"
|
"url": "https://github.com/sponsors/cyyynthia"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sql.js": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
"scripts/lib/",
|
"scripts/lib/",
|
||||||
"scripts/claw.js",
|
"scripts/claw.js",
|
||||||
"scripts/doctor.js",
|
"scripts/doctor.js",
|
||||||
|
"scripts/status.js",
|
||||||
|
"scripts/sessions-cli.js",
|
||||||
"scripts/install-apply.js",
|
"scripts/install-apply.js",
|
||||||
"scripts/install-plan.js",
|
"scripts/install-plan.js",
|
||||||
"scripts/list-installed.js",
|
"scripts/list-installed.js",
|
||||||
@@ -102,6 +104,9 @@
|
|||||||
"test": "node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && node tests/run-all.js",
|
"test": "node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && node tests/run-all.js",
|
||||||
"coverage": "c8 --all --include=\"scripts/**/*.js\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js"
|
"coverage": "c8 --all --include=\"scripts/**/*.js\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"sql.js": "^1.14.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"ajv": "^8.18.0",
|
"ajv": "^8.18.0",
|
||||||
|
|||||||
316
schemas/state-store.schema.json
Normal file
316
schemas/state-store.schema.json
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "ecc.state-store.v1",
|
||||||
|
"title": "ECC State Store Schema",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"sessions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skillRuns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/skillRun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skillVersions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/skillVersion"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/decision"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installState": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/installState"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"governanceEvents": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/governanceEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"nonEmptyString": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"nullableString": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullableInteger": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"jsonValue": {
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"array",
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"jsonArray": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"adapterId",
|
||||||
|
"harness",
|
||||||
|
"state",
|
||||||
|
"repoRoot",
|
||||||
|
"startedAt",
|
||||||
|
"endedAt",
|
||||||
|
"snapshot"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"adapterId": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"harness": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"repoRoot": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"endedAt": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"array"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skillRun": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"skillId",
|
||||||
|
"skillVersion",
|
||||||
|
"sessionId",
|
||||||
|
"taskDescription",
|
||||||
|
"outcome",
|
||||||
|
"failureReason",
|
||||||
|
"tokensUsed",
|
||||||
|
"durationMs",
|
||||||
|
"userFeedback",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"skillId": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"skillVersion": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"sessionId": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"taskDescription": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"outcome": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"failureReason": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"tokensUsed": {
|
||||||
|
"$ref": "#/$defs/nullableInteger"
|
||||||
|
},
|
||||||
|
"durationMs": {
|
||||||
|
"$ref": "#/$defs/nullableInteger"
|
||||||
|
},
|
||||||
|
"userFeedback": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skillVersion": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"skillId",
|
||||||
|
"version",
|
||||||
|
"contentHash",
|
||||||
|
"amendmentReason",
|
||||||
|
"promotedAt",
|
||||||
|
"rolledBackAt"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"skillId": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"contentHash": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"amendmentReason": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"promotedAt": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"rolledBackAt": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"sessionId",
|
||||||
|
"title",
|
||||||
|
"rationale",
|
||||||
|
"alternatives",
|
||||||
|
"supersedes",
|
||||||
|
"status",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"sessionId": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"rationale": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"alternatives": {
|
||||||
|
"$ref": "#/$defs/jsonArray"
|
||||||
|
},
|
||||||
|
"supersedes": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installState": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"targetId",
|
||||||
|
"targetRoot",
|
||||||
|
"profile",
|
||||||
|
"modules",
|
||||||
|
"operations",
|
||||||
|
"installedAt",
|
||||||
|
"sourceVersion"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"targetId": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"targetRoot": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"$ref": "#/$defs/jsonArray"
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"$ref": "#/$defs/jsonArray"
|
||||||
|
},
|
||||||
|
"installedAt": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"sourceVersion": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"governanceEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"sessionId",
|
||||||
|
"eventType",
|
||||||
|
"payload",
|
||||||
|
"resolvedAt",
|
||||||
|
"resolution",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"sessionId": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"eventType": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"$ref": "#/$defs/jsonValue"
|
||||||
|
},
|
||||||
|
"resolvedAt": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,14 @@ const COMMANDS = {
|
|||||||
script: 'repair.js',
|
script: 'repair.js',
|
||||||
description: 'Restore drifted or missing ECC-managed files',
|
description: 'Restore drifted or missing ECC-managed files',
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
script: 'status.js',
|
||||||
|
description: 'Query the ECC SQLite state store status summary',
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
script: 'sessions-cli.js',
|
||||||
|
description: 'List or inspect ECC sessions from the SQLite state store',
|
||||||
|
},
|
||||||
'session-inspect': {
|
'session-inspect': {
|
||||||
script: 'session-inspect.js',
|
script: 'session-inspect.js',
|
||||||
description: 'Emit canonical ECC session snapshots from dmux or Claude history targets',
|
description: 'Emit canonical ECC session snapshots from dmux or Claude history targets',
|
||||||
@@ -45,6 +53,8 @@ const PRIMARY_COMMANDS = [
|
|||||||
'list-installed',
|
'list-installed',
|
||||||
'doctor',
|
'doctor',
|
||||||
'repair',
|
'repair',
|
||||||
|
'status',
|
||||||
|
'sessions',
|
||||||
'session-inspect',
|
'session-inspect',
|
||||||
'uninstall',
|
'uninstall',
|
||||||
];
|
];
|
||||||
@@ -72,6 +82,9 @@ Examples:
|
|||||||
ecc list-installed --json
|
ecc list-installed --json
|
||||||
ecc doctor --target cursor
|
ecc doctor --target cursor
|
||||||
ecc repair --dry-run
|
ecc repair --dry-run
|
||||||
|
ecc status --json
|
||||||
|
ecc sessions
|
||||||
|
ecc sessions session-active --json
|
||||||
ecc session-inspect claude:latest
|
ecc session-inspect claude:latest
|
||||||
ecc uninstall --target antigravity --dry-run
|
ecc uninstall --target antigravity --dry-run
|
||||||
`);
|
`);
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
177
scripts/sessions-cli.js
Normal file
177
scripts/sessions-cli.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createStateStore } = require('./lib/state-store');
|
||||||
|
|
||||||
|
function showHelp(exitCode = 0) {
|
||||||
|
console.log(`
|
||||||
|
Usage: node scripts/sessions-cli.js [<session-id>] [--db <path>] [--json] [--limit <n>]
|
||||||
|
|
||||||
|
List recent ECC sessions from the SQLite state store or inspect a single session
|
||||||
|
with worker, skill-run, and decision detail.
|
||||||
|
`);
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = argv.slice(2);
|
||||||
|
const parsed = {
|
||||||
|
dbPath: null,
|
||||||
|
help: false,
|
||||||
|
json: false,
|
||||||
|
limit: 10,
|
||||||
|
sessionId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
|
||||||
|
if (arg === '--db') {
|
||||||
|
parsed.dbPath = args[index + 1] || null;
|
||||||
|
index += 1;
|
||||||
|
} else if (arg === '--json') {
|
||||||
|
parsed.json = true;
|
||||||
|
} else if (arg === '--limit') {
|
||||||
|
parsed.limit = args[index + 1] || null;
|
||||||
|
index += 1;
|
||||||
|
} else if (arg === '--help' || arg === '-h') {
|
||||||
|
parsed.help = true;
|
||||||
|
} else if (!arg.startsWith('--') && !parsed.sessionId) {
|
||||||
|
parsed.sessionId = arg;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSessionList(payload) {
|
||||||
|
console.log('Recent sessions:\n');
|
||||||
|
|
||||||
|
if (payload.sessions.length === 0) {
|
||||||
|
console.log('No sessions found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of payload.sessions) {
|
||||||
|
console.log(`- ${session.id} [${session.harness}/${session.adapterId}] ${session.state}`);
|
||||||
|
console.log(` Repo: ${session.repoRoot || '(unknown)'}`);
|
||||||
|
console.log(` Started: ${session.startedAt || '(unknown)'}`);
|
||||||
|
console.log(` Ended: ${session.endedAt || '(active)'}`);
|
||||||
|
console.log(` Workers: ${session.workerCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTotal sessions: ${payload.totalCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printWorkers(workers) {
|
||||||
|
console.log(`Workers: ${workers.length}`);
|
||||||
|
if (workers.length === 0) {
|
||||||
|
console.log(' - none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const worker of workers) {
|
||||||
|
console.log(` - ${worker.id || worker.label || '(unknown)'} ${worker.state || 'unknown'}`);
|
||||||
|
console.log(` Branch: ${worker.branch || '(unknown)'}`);
|
||||||
|
console.log(` Worktree: ${worker.worktree || '(unknown)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSkillRuns(skillRuns) {
|
||||||
|
console.log(`Skill runs: ${skillRuns.length}`);
|
||||||
|
if (skillRuns.length === 0) {
|
||||||
|
console.log(' - none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const skillRun of skillRuns) {
|
||||||
|
console.log(` - ${skillRun.id} ${skillRun.outcome} ${skillRun.skillId}@${skillRun.skillVersion}`);
|
||||||
|
console.log(` Task: ${skillRun.taskDescription}`);
|
||||||
|
console.log(` Duration: ${skillRun.durationMs ?? '(unknown)'} ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDecisions(decisions) {
|
||||||
|
console.log(`Decisions: ${decisions.length}`);
|
||||||
|
if (decisions.length === 0) {
|
||||||
|
console.log(' - none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decision of decisions) {
|
||||||
|
console.log(` - ${decision.id} ${decision.status}`);
|
||||||
|
console.log(` Title: ${decision.title}`);
|
||||||
|
console.log(` Alternatives: ${decision.alternatives.join(', ') || '(none)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSessionDetail(payload) {
|
||||||
|
console.log(`Session: ${payload.session.id}`);
|
||||||
|
console.log(`Harness: ${payload.session.harness}`);
|
||||||
|
console.log(`Adapter: ${payload.session.adapterId}`);
|
||||||
|
console.log(`State: ${payload.session.state}`);
|
||||||
|
console.log(`Repo: ${payload.session.repoRoot || '(unknown)'}`);
|
||||||
|
console.log(`Started: ${payload.session.startedAt || '(unknown)'}`);
|
||||||
|
console.log(`Ended: ${payload.session.endedAt || '(active)'}`);
|
||||||
|
console.log();
|
||||||
|
printWorkers(payload.workers);
|
||||||
|
console.log();
|
||||||
|
printSkillRuns(payload.skillRuns);
|
||||||
|
console.log();
|
||||||
|
printDecisions(payload.decisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let store = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = parseArgs(process.argv);
|
||||||
|
if (options.help) {
|
||||||
|
showHelp(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
store = await createStateStore({
|
||||||
|
dbPath: options.dbPath,
|
||||||
|
homeDir: process.env.HOME,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!options.sessionId) {
|
||||||
|
const payload = store.listRecentSessions({ limit: options.limit });
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
} else {
|
||||||
|
printSessionList(payload);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = store.getSessionDetail(options.sessionId);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error(`Session not found: ${options.sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
} else {
|
||||||
|
printSessionDetail(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
if (store) {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
main,
|
||||||
|
parseArgs,
|
||||||
|
};
|
||||||
176
scripts/status.js
Normal file
176
scripts/status.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createStateStore } = require('./lib/state-store');
|
||||||
|
|
||||||
|
function showHelp(exitCode = 0) {
|
||||||
|
console.log(`
|
||||||
|
Usage: node scripts/status.js [--db <path>] [--json] [--limit <n>]
|
||||||
|
|
||||||
|
Query the ECC SQLite state store for active sessions, recent skill runs,
|
||||||
|
install health, and pending governance events.
|
||||||
|
`);
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = argv.slice(2);
|
||||||
|
const parsed = {
|
||||||
|
dbPath: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
limit: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
|
||||||
|
if (arg === '--db') {
|
||||||
|
parsed.dbPath = args[index + 1] || null;
|
||||||
|
index += 1;
|
||||||
|
} else if (arg === '--json') {
|
||||||
|
parsed.json = true;
|
||||||
|
} else if (arg === '--limit') {
|
||||||
|
parsed.limit = args[index + 1] || null;
|
||||||
|
index += 1;
|
||||||
|
} else if (arg === '--help' || arg === '-h') {
|
||||||
|
parsed.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printActiveSessions(section) {
|
||||||
|
console.log(`Active sessions: ${section.activeCount}`);
|
||||||
|
if (section.sessions.length === 0) {
|
||||||
|
console.log(' - none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of section.sessions) {
|
||||||
|
console.log(` - ${session.id} [${session.harness}/${session.adapterId}] ${session.state}`);
|
||||||
|
console.log(` Repo: ${session.repoRoot || '(unknown)'}`);
|
||||||
|
console.log(` Started: ${session.startedAt || '(unknown)'}`);
|
||||||
|
console.log(` Workers: ${session.workerCount}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSkillRuns(section) {
|
||||||
|
const summary = section.summary;
|
||||||
|
const successRate = summary.successRate === null ? 'n/a' : `${summary.successRate}%`;
|
||||||
|
const failureRate = summary.failureRate === null ? 'n/a' : `${summary.failureRate}%`;
|
||||||
|
|
||||||
|
console.log(`Skill runs (last ${section.windowSize}):`);
|
||||||
|
console.log(` Success: ${summary.successCount}`);
|
||||||
|
console.log(` Failure: ${summary.failureCount}`);
|
||||||
|
console.log(` Unknown: ${summary.unknownCount}`);
|
||||||
|
console.log(` Success rate: ${successRate}`);
|
||||||
|
console.log(` Failure rate: ${failureRate}`);
|
||||||
|
|
||||||
|
if (section.recent.length === 0) {
|
||||||
|
console.log(' Recent runs: none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' Recent runs:');
|
||||||
|
for (const skillRun of section.recent.slice(0, 5)) {
|
||||||
|
console.log(` - ${skillRun.id} ${skillRun.outcome} ${skillRun.skillId}@${skillRun.skillVersion}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printInstallHealth(section) {
|
||||||
|
console.log(`Install health: ${section.status}`);
|
||||||
|
console.log(` Targets recorded: ${section.totalCount}`);
|
||||||
|
console.log(` Healthy: ${section.healthyCount}`);
|
||||||
|
console.log(` Warning: ${section.warningCount}`);
|
||||||
|
|
||||||
|
if (section.installations.length === 0) {
|
||||||
|
console.log(' Installations: none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' Installations:');
|
||||||
|
for (const installation of section.installations.slice(0, 5)) {
|
||||||
|
console.log(` - ${installation.targetId} ${installation.status}`);
|
||||||
|
console.log(` Root: ${installation.targetRoot}`);
|
||||||
|
console.log(` Profile: ${installation.profile || '(custom)'}`);
|
||||||
|
console.log(` Modules: ${installation.moduleCount}`);
|
||||||
|
console.log(` Source version: ${installation.sourceVersion || '(unknown)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printGovernance(section) {
|
||||||
|
console.log(`Pending governance events: ${section.pendingCount}`);
|
||||||
|
if (section.events.length === 0) {
|
||||||
|
console.log(' - none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of section.events) {
|
||||||
|
console.log(` - ${event.id} ${event.eventType}`);
|
||||||
|
console.log(` Session: ${event.sessionId || '(none)'}`);
|
||||||
|
console.log(` Created: ${event.createdAt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHuman(payload) {
|
||||||
|
console.log('ECC status\n');
|
||||||
|
console.log(`Database: ${payload.dbPath}\n`);
|
||||||
|
printActiveSessions(payload.activeSessions);
|
||||||
|
console.log();
|
||||||
|
printSkillRuns(payload.skillRuns);
|
||||||
|
console.log();
|
||||||
|
printInstallHealth(payload.installHealth);
|
||||||
|
console.log();
|
||||||
|
printGovernance(payload.governance);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let store = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = parseArgs(process.argv);
|
||||||
|
if (options.help) {
|
||||||
|
showHelp(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
store = await createStateStore({
|
||||||
|
dbPath: options.dbPath,
|
||||||
|
homeDir: process.env.HOME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
dbPath: store.dbPath,
|
||||||
|
...store.getStatus({
|
||||||
|
activeLimit: options.limit,
|
||||||
|
recentSkillRunLimit: 20,
|
||||||
|
pendingLimit: options.limit,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
} else {
|
||||||
|
printHuman(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
if (store) {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
main,
|
||||||
|
parseArgs,
|
||||||
|
};
|
||||||
489
tests/lib/state-store.test.js
Normal file
489
tests/lib/state-store.test.js
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the SQLite-backed ECC state store and CLI commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createStateStore,
|
||||||
|
resolveStateStorePath,
|
||||||
|
} = require('../../scripts/lib/state-store');
|
||||||
|
|
||||||
|
const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
||||||
|
const STATUS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'status.js');
|
||||||
|
const SESSIONS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'sessions-cli.js');
|
||||||
|
|
||||||
|
async function test(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` \u2717 ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTempDir(prefix) {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupTempDir(dirPath) {
|
||||||
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNode(scriptPath, args = [], options = {}) {
|
||||||
|
return spawnSync('node', [scriptPath, ...args], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: options.cwd || process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(options.env || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(stdout) {
|
||||||
|
return JSON.parse(stdout.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStore(dbPath) {
|
||||||
|
const store = await createStateStore({ dbPath });
|
||||||
|
|
||||||
|
store.upsertSession({
|
||||||
|
id: 'session-active',
|
||||||
|
adapterId: 'dmux-tmux',
|
||||||
|
harness: 'claude',
|
||||||
|
state: 'active',
|
||||||
|
repoRoot: '/tmp/ecc-repo',
|
||||||
|
startedAt: '2026-03-15T08:00:00.000Z',
|
||||||
|
endedAt: null,
|
||||||
|
snapshot: {
|
||||||
|
schemaVersion: 'ecc.session.v1',
|
||||||
|
adapterId: 'dmux-tmux',
|
||||||
|
session: {
|
||||||
|
id: 'session-active',
|
||||||
|
kind: 'orchestrated',
|
||||||
|
state: 'active',
|
||||||
|
repoRoot: '/tmp/ecc-repo',
|
||||||
|
},
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
id: 'worker-1',
|
||||||
|
label: 'Worker 1',
|
||||||
|
state: 'active',
|
||||||
|
branch: 'feat/state-store',
|
||||||
|
worktree: '/tmp/ecc-repo/.worktrees/worker-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'worker-2',
|
||||||
|
label: 'Worker 2',
|
||||||
|
state: 'idle',
|
||||||
|
branch: 'feat/state-store',
|
||||||
|
worktree: '/tmp/ecc-repo/.worktrees/worker-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aggregates: {
|
||||||
|
workerCount: 2,
|
||||||
|
states: {
|
||||||
|
active: 1,
|
||||||
|
idle: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store.upsertSession({
|
||||||
|
id: 'session-recorded',
|
||||||
|
adapterId: 'claude-history',
|
||||||
|
harness: 'claude',
|
||||||
|
state: 'recorded',
|
||||||
|
repoRoot: '/tmp/ecc-repo',
|
||||||
|
startedAt: '2026-03-14T18:00:00.000Z',
|
||||||
|
endedAt: '2026-03-14T19:00:00.000Z',
|
||||||
|
snapshot: {
|
||||||
|
schemaVersion: 'ecc.session.v1',
|
||||||
|
adapterId: 'claude-history',
|
||||||
|
session: {
|
||||||
|
id: 'session-recorded',
|
||||||
|
kind: 'history',
|
||||||
|
state: 'recorded',
|
||||||
|
repoRoot: '/tmp/ecc-repo',
|
||||||
|
},
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
id: 'worker-hist',
|
||||||
|
label: 'History Worker',
|
||||||
|
state: 'recorded',
|
||||||
|
branch: 'main',
|
||||||
|
worktree: '/tmp/ecc-repo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aggregates: {
|
||||||
|
workerCount: 1,
|
||||||
|
states: {
|
||||||
|
recorded: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertSkillRun({
|
||||||
|
id: 'skill-run-1',
|
||||||
|
skillId: 'tdd-workflow',
|
||||||
|
skillVersion: '1.0.0',
|
||||||
|
sessionId: 'session-active',
|
||||||
|
taskDescription: 'Write store tests',
|
||||||
|
outcome: 'success',
|
||||||
|
failureReason: null,
|
||||||
|
tokensUsed: 1200,
|
||||||
|
durationMs: 3500,
|
||||||
|
userFeedback: 'useful',
|
||||||
|
createdAt: '2026-03-15T08:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertSkillRun({
|
||||||
|
id: 'skill-run-2',
|
||||||
|
skillId: 'security-review',
|
||||||
|
skillVersion: '1.0.0',
|
||||||
|
sessionId: 'session-active',
|
||||||
|
taskDescription: 'Review state-store design',
|
||||||
|
outcome: 'failed',
|
||||||
|
failureReason: 'timeout',
|
||||||
|
tokensUsed: 800,
|
||||||
|
durationMs: 1800,
|
||||||
|
userFeedback: null,
|
||||||
|
createdAt: '2026-03-15T08:06:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertSkillRun({
|
||||||
|
id: 'skill-run-3',
|
||||||
|
skillId: 'code-reviewer',
|
||||||
|
skillVersion: '1.0.0',
|
||||||
|
sessionId: 'session-recorded',
|
||||||
|
taskDescription: 'Inspect CLI formatting',
|
||||||
|
outcome: 'success',
|
||||||
|
failureReason: null,
|
||||||
|
tokensUsed: 500,
|
||||||
|
durationMs: 900,
|
||||||
|
userFeedback: 'clear',
|
||||||
|
createdAt: '2026-03-15T08:07:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertSkillRun({
|
||||||
|
id: 'skill-run-4',
|
||||||
|
skillId: 'planner',
|
||||||
|
skillVersion: '1.0.0',
|
||||||
|
sessionId: 'session-recorded',
|
||||||
|
taskDescription: 'Outline ECC 2.0 work',
|
||||||
|
outcome: 'unknown',
|
||||||
|
failureReason: null,
|
||||||
|
tokensUsed: 300,
|
||||||
|
durationMs: 500,
|
||||||
|
userFeedback: null,
|
||||||
|
createdAt: '2026-03-15T08:08:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.upsertSkillVersion({
|
||||||
|
skillId: 'tdd-workflow',
|
||||||
|
version: '1.0.0',
|
||||||
|
contentHash: 'abc123',
|
||||||
|
amendmentReason: 'initial',
|
||||||
|
promotedAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
rolledBackAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertDecision({
|
||||||
|
id: 'decision-1',
|
||||||
|
sessionId: 'session-active',
|
||||||
|
title: 'Use SQLite for durable state',
|
||||||
|
rationale: 'Need queryable local state for ECC control plane',
|
||||||
|
alternatives: ['json-files', 'memory-only'],
|
||||||
|
supersedes: null,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-03-15T08:09:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.upsertInstallState({
|
||||||
|
targetId: 'claude-home',
|
||||||
|
targetRoot: '/tmp/home/.claude',
|
||||||
|
profile: 'developer',
|
||||||
|
modules: ['rules-core', 'orchestration'],
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
kind: 'copy-file',
|
||||||
|
destinationPath: '/tmp/home/.claude/agents/planner.md',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
installedAt: '2026-03-15T07:00:00.000Z',
|
||||||
|
sourceVersion: '1.8.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertGovernanceEvent({
|
||||||
|
id: 'gov-1',
|
||||||
|
sessionId: 'session-active',
|
||||||
|
eventType: 'policy-review-required',
|
||||||
|
payload: {
|
||||||
|
severity: 'warning',
|
||||||
|
owner: 'security-reviewer',
|
||||||
|
},
|
||||||
|
resolvedAt: null,
|
||||||
|
resolution: null,
|
||||||
|
createdAt: '2026-03-15T08:10:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.insertGovernanceEvent({
|
||||||
|
id: 'gov-2',
|
||||||
|
sessionId: 'session-recorded',
|
||||||
|
eventType: 'decision-accepted',
|
||||||
|
payload: {
|
||||||
|
severity: 'info',
|
||||||
|
},
|
||||||
|
resolvedAt: '2026-03-15T08:11:00.000Z',
|
||||||
|
resolution: 'accepted',
|
||||||
|
createdAt: '2026-03-15T08:09:30.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log('\n=== Testing state-store ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
if (await test('creates the default state.db path and applies migrations idempotently', async () => {
|
||||||
|
const homeDir = createTempDir('ecc-state-home-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedPath = path.join(homeDir, '.claude', 'ecc', 'state.db');
|
||||||
|
assert.strictEqual(resolveStateStorePath({ homeDir }), expectedPath);
|
||||||
|
|
||||||
|
const firstStore = await createStateStore({ homeDir });
|
||||||
|
const firstMigrations = firstStore.getAppliedMigrations();
|
||||||
|
firstStore.close();
|
||||||
|
|
||||||
|
assert.strictEqual(firstMigrations.length, 1);
|
||||||
|
assert.strictEqual(firstMigrations[0].version, 1);
|
||||||
|
assert.ok(fs.existsSync(expectedPath));
|
||||||
|
|
||||||
|
const secondStore = await createStateStore({ homeDir });
|
||||||
|
const secondMigrations = secondStore.getAppliedMigrations();
|
||||||
|
secondStore.close();
|
||||||
|
|
||||||
|
assert.strictEqual(secondMigrations.length, 1);
|
||||||
|
assert.strictEqual(secondMigrations[0].version, 1);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(homeDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('preserves SQLite special database names like :memory:', async () => {
|
||||||
|
const tempDir = createTempDir('ecc-state-memory-');
|
||||||
|
const previousCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(tempDir);
|
||||||
|
assert.strictEqual(resolveStateStorePath({ dbPath: ':memory:' }), ':memory:');
|
||||||
|
|
||||||
|
const store = await createStateStore({ dbPath: ':memory:' });
|
||||||
|
assert.strictEqual(store.dbPath, ':memory:');
|
||||||
|
assert.strictEqual(store.getAppliedMigrations().length, 1);
|
||||||
|
store.close();
|
||||||
|
|
||||||
|
assert.ok(!fs.existsSync(path.join(tempDir, ':memory:')));
|
||||||
|
} finally {
|
||||||
|
process.chdir(previousCwd);
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('stores sessions and returns detailed session views with workers, skill runs, and decisions', async () => {
|
||||||
|
const testDir = createTempDir('ecc-state-db-');
|
||||||
|
const dbPath = path.join(testDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedStore(dbPath);
|
||||||
|
|
||||||
|
const store = await createStateStore({ dbPath });
|
||||||
|
const listResult = store.listRecentSessions({ limit: 10 });
|
||||||
|
const detail = store.getSessionDetail('session-active');
|
||||||
|
store.close();
|
||||||
|
|
||||||
|
assert.strictEqual(listResult.totalCount, 2);
|
||||||
|
assert.strictEqual(listResult.sessions[0].id, 'session-active');
|
||||||
|
assert.strictEqual(detail.session.id, 'session-active');
|
||||||
|
assert.strictEqual(detail.workers.length, 2);
|
||||||
|
assert.strictEqual(detail.skillRuns.length, 2);
|
||||||
|
assert.strictEqual(detail.decisions.length, 1);
|
||||||
|
assert.deepStrictEqual(detail.decisions[0].alternatives, ['json-files', 'memory-only']);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(testDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('builds a status snapshot with active sessions, skill rates, install health, and pending governance', async () => {
|
||||||
|
const testDir = createTempDir('ecc-state-db-');
|
||||||
|
const dbPath = path.join(testDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedStore(dbPath);
|
||||||
|
|
||||||
|
const store = await createStateStore({ dbPath });
|
||||||
|
const status = store.getStatus();
|
||||||
|
store.close();
|
||||||
|
|
||||||
|
assert.strictEqual(status.activeSessions.activeCount, 1);
|
||||||
|
assert.strictEqual(status.activeSessions.sessions[0].id, 'session-active');
|
||||||
|
assert.strictEqual(status.skillRuns.summary.totalCount, 4);
|
||||||
|
assert.strictEqual(status.skillRuns.summary.successCount, 2);
|
||||||
|
assert.strictEqual(status.skillRuns.summary.failureCount, 1);
|
||||||
|
assert.strictEqual(status.skillRuns.summary.unknownCount, 1);
|
||||||
|
assert.strictEqual(status.installHealth.status, 'healthy');
|
||||||
|
assert.strictEqual(status.installHealth.totalCount, 1);
|
||||||
|
assert.strictEqual(status.governance.pendingCount, 1);
|
||||||
|
assert.strictEqual(status.governance.events[0].id, 'gov-1');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(testDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('validates entity payloads before writing to the database', async () => {
|
||||||
|
const testDir = createTempDir('ecc-state-db-');
|
||||||
|
const dbPath = path.join(testDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = await createStateStore({ dbPath });
|
||||||
|
assert.throws(() => {
|
||||||
|
store.upsertSession({
|
||||||
|
id: '',
|
||||||
|
adapterId: 'dmux-tmux',
|
||||||
|
harness: 'claude',
|
||||||
|
state: 'active',
|
||||||
|
repoRoot: '/tmp/repo',
|
||||||
|
startedAt: '2026-03-15T08:00:00.000Z',
|
||||||
|
endedAt: null,
|
||||||
|
snapshot: {},
|
||||||
|
});
|
||||||
|
}, /Invalid session/);
|
||||||
|
|
||||||
|
assert.throws(() => {
|
||||||
|
store.insertDecision({
|
||||||
|
id: 'decision-invalid',
|
||||||
|
sessionId: 'missing-session',
|
||||||
|
title: 'Reject non-array alternatives',
|
||||||
|
rationale: 'alternatives must be an array',
|
||||||
|
alternatives: { unexpected: true },
|
||||||
|
supersedes: null,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-03-15T08:15:00.000Z',
|
||||||
|
});
|
||||||
|
}, /Invalid decision/);
|
||||||
|
|
||||||
|
assert.throws(() => {
|
||||||
|
store.upsertInstallState({
|
||||||
|
targetId: 'claude-home',
|
||||||
|
targetRoot: '/tmp/home/.claude',
|
||||||
|
profile: 'developer',
|
||||||
|
modules: 'rules-core',
|
||||||
|
operations: [],
|
||||||
|
installedAt: '2026-03-15T07:00:00.000Z',
|
||||||
|
sourceVersion: '1.8.0',
|
||||||
|
});
|
||||||
|
}, /Invalid installState/);
|
||||||
|
|
||||||
|
store.close();
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(testDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('status CLI supports human-readable and --json output', async () => {
|
||||||
|
const testDir = createTempDir('ecc-state-cli-');
|
||||||
|
const dbPath = path.join(testDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedStore(dbPath);
|
||||||
|
|
||||||
|
const jsonResult = runNode(STATUS_SCRIPT, ['--db', dbPath, '--json']);
|
||||||
|
assert.strictEqual(jsonResult.status, 0, jsonResult.stderr);
|
||||||
|
const jsonPayload = parseJson(jsonResult.stdout);
|
||||||
|
assert.strictEqual(jsonPayload.activeSessions.activeCount, 1);
|
||||||
|
assert.strictEqual(jsonPayload.governance.pendingCount, 1);
|
||||||
|
|
||||||
|
const humanResult = runNode(STATUS_SCRIPT, ['--db', dbPath]);
|
||||||
|
assert.strictEqual(humanResult.status, 0, humanResult.stderr);
|
||||||
|
assert.match(humanResult.stdout, /Active sessions: 1/);
|
||||||
|
assert.match(humanResult.stdout, /Skill runs \(last 20\):/);
|
||||||
|
assert.match(humanResult.stdout, /Install health: healthy/);
|
||||||
|
assert.match(humanResult.stdout, /Pending governance events: 1/);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(testDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('sessions CLI supports list and detail views in human-readable and --json output', async () => {
|
||||||
|
const testDir = createTempDir('ecc-state-cli-');
|
||||||
|
const dbPath = path.join(testDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedStore(dbPath);
|
||||||
|
|
||||||
|
const listJsonResult = runNode(SESSIONS_SCRIPT, ['--db', dbPath, '--json']);
|
||||||
|
assert.strictEqual(listJsonResult.status, 0, listJsonResult.stderr);
|
||||||
|
const listPayload = parseJson(listJsonResult.stdout);
|
||||||
|
assert.strictEqual(listPayload.totalCount, 2);
|
||||||
|
assert.strictEqual(listPayload.sessions[0].id, 'session-active');
|
||||||
|
|
||||||
|
const detailJsonResult = runNode(SESSIONS_SCRIPT, ['session-active', '--db', dbPath, '--json']);
|
||||||
|
assert.strictEqual(detailJsonResult.status, 0, detailJsonResult.stderr);
|
||||||
|
const detailPayload = parseJson(detailJsonResult.stdout);
|
||||||
|
assert.strictEqual(detailPayload.session.id, 'session-active');
|
||||||
|
assert.strictEqual(detailPayload.workers.length, 2);
|
||||||
|
assert.strictEqual(detailPayload.skillRuns.length, 2);
|
||||||
|
assert.strictEqual(detailPayload.decisions.length, 1);
|
||||||
|
|
||||||
|
const detailHumanResult = runNode(SESSIONS_SCRIPT, ['session-active', '--db', dbPath]);
|
||||||
|
assert.strictEqual(detailHumanResult.status, 0, detailHumanResult.stderr);
|
||||||
|
assert.match(detailHumanResult.stdout, /Session: session-active/);
|
||||||
|
assert.match(detailHumanResult.stdout, /Workers: 2/);
|
||||||
|
assert.match(detailHumanResult.stdout, /Skill runs: 2/);
|
||||||
|
assert.match(detailHumanResult.stdout, /Decisions: 1/);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(testDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
if (await test('ecc CLI delegates the new status and sessions subcommands', async () => {
|
||||||
|
const testDir = createTempDir('ecc-state-cli-');
|
||||||
|
const dbPath = path.join(testDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedStore(dbPath);
|
||||||
|
|
||||||
|
const statusResult = runNode(ECC_SCRIPT, ['status', '--db', dbPath, '--json']);
|
||||||
|
assert.strictEqual(statusResult.status, 0, statusResult.stderr);
|
||||||
|
const statusPayload = parseJson(statusResult.stdout);
|
||||||
|
assert.strictEqual(statusPayload.activeSessions.activeCount, 1);
|
||||||
|
|
||||||
|
const sessionsResult = runNode(ECC_SCRIPT, ['sessions', 'session-active', '--db', dbPath, '--json']);
|
||||||
|
assert.strictEqual(sessionsResult.status, 0, sessionsResult.stderr);
|
||||||
|
const sessionsPayload = parseJson(sessionsResult.stdout);
|
||||||
|
assert.strictEqual(sessionsPayload.session.id, 'session-active');
|
||||||
|
assert.strictEqual(sessionsPayload.skillRuns.length, 2);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(testDir);
|
||||||
|
}
|
||||||
|
})) passed += 1; else failed += 1;
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
Reference in New Issue
Block a user