From 0f84c0e2796703fbda87d577b2636351418c7442 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 3 Jun 2026 21:54:30 +0800 Subject: [PATCH] feat: add ECC2 local control pane (#2131) * feat: add ECC2 local control pane * fix: refresh control pane package locks * test: harden control pane coverage * test: allow portable control pane shutdown * test: retry local control pane fetches * fix: harden control pane error handling * fix: wrap control pane metadata --- package-lock.json | 1 + package.json | 3 + scripts/control-pane.js | 65 +++ scripts/ecc.js | 6 + scripts/lib/control-pane/actions.js | 133 +++++ scripts/lib/control-pane/server.js | 270 +++++++++ scripts/lib/control-pane/state.js | 518 +++++++++++++++++ scripts/lib/control-pane/ui.js | 554 ++++++++++++++++++ tests/lib/control-pane-actions.test.js | 100 ++++ tests/lib/control-pane-state.test.js | 663 ++++++++++++++++++++++ tests/scripts/control-pane.test.js | 423 ++++++++++++++ tests/scripts/ecc.test.js | 7 + tests/scripts/npm-publish-surface.test.js | 2 + yarn.lock | 1 + 14 files changed, 2746 insertions(+) create mode 100755 scripts/control-pane.js create mode 100644 scripts/lib/control-pane/actions.js create mode 100644 scripts/lib/control-pane/server.js create mode 100644 scripts/lib/control-pane/state.js create mode 100644 scripts/lib/control-pane/ui.js create mode 100644 tests/lib/control-pane-actions.test.js create mode 100644 tests/lib/control-pane-state.test.js create mode 100644 tests/scripts/control-pane.test.js diff --git a/package-lock.json b/package-lock.json index b2306cd3..315102fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ }, "bin": { "ecc": "scripts/ecc.js", + "ecc-control-pane": "scripts/control-pane.js", "ecc-install": "scripts/install-apply.js" }, "devDependencies": { diff --git a/package.json b/package.json index 5e3474a7..db60ac2a 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "scripts/consult.js", "scripts/auto-update.js", "scripts/claw.js", + "scripts/control-pane.js", "scripts/codex/merge-codex-config.js", "scripts/codex/merge-mcp-config.js", "scripts/discussion-audit.js", @@ -314,6 +315,7 @@ ], "bin": { "ecc": "scripts/ecc.js", + "ecc-control-pane": "scripts/control-pane.js", "ecc-install": "scripts/install-apply.js" }, "scripts": { @@ -331,6 +333,7 @@ "preview-pack:smoke": "node scripts/preview-pack-smoke.js", "release:approval-gate": "node scripts/release-approval-gate.js", "release:video-suite": "node scripts/release-video-suite.js", + "control:pane": "node scripts/control-pane.js", "platform:audit": "node scripts/platform-audit.js", "discussion:audit": "node scripts/discussion-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", diff --git a/scripts/control-pane.js b/scripts/control-pane.js new file mode 100755 index 00000000..735e8601 --- /dev/null +++ b/scripts/control-pane.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +'use strict'; + +const { spawn } = require('child_process'); + +const { + createControlPaneServer, + parseArgs, + usage, +} = require('./lib/control-pane/server'); + +function openBrowser(url) { + if (process.platform !== 'darwin') return; + const child = spawn('open', [url], { + stdio: 'ignore', + detached: true, + }); + child.on('error', error => { + console.error(`[control-pane] failed to open browser: ${error.message}`); + }); + child.unref(); +} + +async function main(argv = process.argv) { + const args = parseArgs(argv); + + if (args.help) { + console.log(usage()); + return; + } + + const app = createControlPaneServer(args); + await app.listen(); + + console.log(`ECC Control Pane: ${app.url}`); + console.log(`ECC2 database: ${app.config.dbPath}`); + console.log(args.allowActions ? 'Actions: enabled for local allowlist' : 'Actions: read-only'); + + if (args.openBrowser) { + openBrowser(app.url); + } + + const shutdown = async () => { + try { + await app.close(); + } finally { + process.exit(0); + } + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +if (require.main === module) { + main().catch(error => { + console.error(`[control-pane] ${error.message}`); + process.exit(1); + }); +} + +module.exports = { + main, + openBrowser, +}; diff --git a/scripts/ecc.js b/scripts/ecc.js index c637ef27..946a1fc7 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -21,6 +21,10 @@ const COMMANDS = { script: 'consult.js', description: 'Recommend ECC components and profiles from a natural language query', }, + 'control-pane': { + script: 'control-pane.js', + description: 'Run the local ECC2 operator control pane', + }, 'install-plan': { script: 'install-plan.js', description: 'Alias for plan', @@ -80,6 +84,7 @@ const PRIMARY_COMMANDS = [ 'plan', 'catalog', 'consult', + 'control-pane', 'list-installed', 'doctor', 'repair', @@ -118,6 +123,7 @@ Examples: ecc catalog components --family language ecc catalog show framework:nextjs ecc consult "security reviews" + ecc control-pane --port 8765 ecc list-installed --json ecc doctor --target cursor ecc repair --dry-run diff --git a/scripts/lib/control-pane/actions.js b/scripts/lib/control-pane/actions.js new file mode 100644 index 00000000..af70e542 --- /dev/null +++ b/scripts/lib/control-pane/actions.js @@ -0,0 +1,133 @@ +'use strict'; + +const path = require('path'); + +const ACTION_DEFINITIONS = new Map([ + [ + 'sync-knowledge', + { + label: 'Sync Knowledge', + description: 'Import all configured ECC2 memory connectors into the context graph.', + args: ({ limit }) => [ + 'run', + '--quiet', + '--', + 'graph', + 'connector-sync', + '--all', + '--json', + '--limit', + String(limit), + ], + executable: true, + }, + ], + [ + 'recall-knowledge', + { + label: 'Recall Knowledge', + description: 'Run ECC2 context recall for the current operator query.', + args: ({ query, limit }) => [ + 'run', + '--quiet', + '--', + 'graph', + 'recall', + query || 'ECC control pane', + '--json', + '--limit', + String(limit), + ], + executable: true, + }, + ], + [ + 'graph-sync', + { + label: 'Backfill Graph', + description: 'Backfill the ECC2 graph from sessions, decisions, file activity, and messages.', + args: ({ limit }) => [ + 'run', + '--quiet', + '--', + 'graph', + 'sync', + '--all', + '--json', + '--limit', + String(limit), + ], + executable: true, + }, + ], + [ + 'open-dashboard', + { + label: 'Open TUI', + description: 'Launch the ECC2 terminal dashboard.', + args: () => ['run', '--quiet', '--', 'dashboard'], + executable: false, + }, + ], +]); + +function normalizeLimit(value, fallback = 25) { + const parsed = Number.parseInt(String(value ?? fallback), 10); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.min(parsed, 500); +} + +function shellQuote(value) { + const text = String(value); + if (text.length === 0) return "''"; + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(text)) return text; + return `'${text.replace(/'/g, `'\\''`)}'`; +} + +function commandLineFor(action) { + return [ + `cd ${shellQuote(action.cwd)}`, + '&&', + shellQuote(action.command), + ...action.args.map(shellQuote), + ].join(' '); +} + +function buildControlPaneAction(actionId, options = {}) { + const definition = ACTION_DEFINITIONS.get(actionId); + if (!definition) { + throw new Error(`Unknown control-pane action: ${actionId}`); + } + + const repoRoot = path.resolve(options.repoRoot || process.cwd()); + const cwd = path.join(repoRoot, 'ecc2'); + const limit = normalizeLimit(options.limit); + const query = String(options.query || '').trim(); + const args = definition.args({ limit, query }); + const action = { + id: actionId, + label: definition.label, + description: definition.description, + command: 'cargo', + args, + cwd, + executable: definition.executable, + }; + + return { + ...action, + commandLine: commandLineFor(action), + }; +} + +function buildControlPaneActions(options = {}) { + return Array.from(ACTION_DEFINITIONS.keys()).map(actionId => + buildControlPaneAction(actionId, options) + ); +} + +module.exports = { + buildControlPaneAction, + buildControlPaneActions, + shellQuote, +}; diff --git a/scripts/lib/control-pane/server.js b/scripts/lib/control-pane/server.js new file mode 100644 index 00000000..b9df1275 --- /dev/null +++ b/scripts/lib/control-pane/server.js @@ -0,0 +1,270 @@ +'use strict'; + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { spawn } = require('child_process'); + +const { buildControlPaneAction } = require('./actions'); +const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state'); +const { renderControlPaneHtml } = require('./ui'); + +function usage() { + return [ + 'Usage:', + ' node scripts/control-pane.js [--host 127.0.0.1] [--port 8765] [--db ] [--config ] [--query ]', + '', + 'Options:', + ' --read-only Disable action execution endpoints', + ' --no-open Do not open a browser after the server starts', + ' --help Show this help', + ].join('\n'); +} + +function valueAfter(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : null; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const help = args.includes('--help') || args.includes('-h'); + const host = valueAfter(args, '--host') || '127.0.0.1'; + const portValue = valueAfter(args, '--port') || '8765'; + const port = Number.parseInt(portValue, 10); + if (!Number.isFinite(port) || port < 0 || port > 65535) { + throw new Error(`Invalid --port value: ${portValue}`); + } + + return { + help, + host, + port, + dbPath: valueAfter(args, '--db'), + configPath: valueAfter(args, '--config'), + query: valueAfter(args, '--query') || '', + openBrowser: !args.includes('--no-open'), + allowActions: !args.includes('--read-only'), + }; +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }); + res.end(`${body}\n`); +} + +function sendText(res, statusCode, body, contentType = 'text/plain; charset=utf-8') { + res.writeHead(statusCode, { + 'content-type': contentType, + 'cache-control': 'no-store', + }); + res.end(body); +} + +async function readRequestJson(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + if (chunks.length === 0) return {}; + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return {}; + return JSON.parse(raw); +} + +function boundedOutput(value, limit = 20000) { + const text = String(value || ''); + if (text.length <= limit) return text; + return `${text.slice(0, limit)}\n[truncated ${text.length - limit} chars]`; +} + +function runAction(action, options = {}) { + const timeoutMs = options.timeoutMs || 120000; + return new Promise(resolve => { + const startedAt = new Date().toISOString(); + const child = spawn(action.command, action.args, { + cwd: action.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { + child.kill('SIGTERM'); + } + }, timeoutMs); + + child.stdout.on('data', chunk => { + stdout += chunk.toString('utf8'); + }); + child.stderr.on('data', chunk => { + stderr += chunk.toString('utf8'); + }); + child.on('error', error => { + settled = true; + clearTimeout(timeout); + resolve({ + ok: false, + action: action.id, + startedAt, + finishedAt: new Date().toISOString(), + code: null, + error: error.message, + stdout: boundedOutput(stdout), + stderr: boundedOutput(stderr), + }); + }); + child.on('close', (code, signal) => { + settled = true; + clearTimeout(timeout); + resolve({ + ok: code === 0, + action: action.id, + startedAt, + finishedAt: new Date().toISOString(), + code, + signal, + stdout: boundedOutput(stdout), + stderr: boundedOutput(stderr), + }); + }); + }); +} + +function createControlPaneServer(options = {}) { + const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..')); + const host = options.host || '127.0.0.1'; + const port = options.port === null || options.port === undefined ? 8765 : options.port; + const allowActions = options.allowActions !== false; + const resolvedConfig = resolveControlPaneConfig({ + cwd: options.cwd || repoRoot, + configPath: options.configPath, + dbPath: options.dbPath, + env: options.env || process.env, + }); + const baseQuery = options.query || ''; + + const server = http.createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url, `http://${host}:${port || 0}`); + + if (req.method === 'GET' && requestUrl.pathname === '/') { + sendText(res, 200, renderControlPaneHtml(), 'text/html; charset=utf-8'); + return; + } + + if (req.method === 'GET' && requestUrl.pathname === '/assets/ecc-icon.svg') { + const iconPath = path.join(repoRoot, 'assets', 'ecc-icon.svg'); + if (!fs.existsSync(iconPath)) { + sendText(res, 404, 'not found'); + return; + } + sendText(res, 200, fs.readFileSync(iconPath, 'utf8'), 'image/svg+xml; charset=utf-8'); + return; + } + + if (req.method === 'GET' && requestUrl.pathname === '/api/health') { + sendJson(res, 200, { + ok: true, + repoRoot, + dbPath: resolvedConfig.dbPath, + allowActions, + }); + return; + } + + if (req.method === 'GET' && requestUrl.pathname === '/api/snapshot') { + const snapshot = await buildControlPaneSnapshot({ + repoRoot, + dbPath: resolvedConfig.dbPath, + config: resolvedConfig, + query: requestUrl.searchParams.get('query') || baseQuery, + limit: requestUrl.searchParams.get('limit') || 12, + allowActions, + }); + sendJson(res, 200, snapshot); + return; + } + + const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/); + if (req.method === 'POST' && actionMatch) { + if (!allowActions) { + sendJson(res, 403, { + ok: false, + error: 'Control-pane action execution is disabled by --read-only.', + }); + return; + } + + const body = await readRequestJson(req); + const action = buildControlPaneAction(decodeURIComponent(actionMatch[1]), { + repoRoot, + query: body.query || baseQuery, + limit: body.limit || 25, + }); + + if (!action.executable) { + sendJson(res, 400, { + ok: false, + action: action.id, + error: 'This action is copy-only and cannot be executed from the browser.', + commandLine: action.commandLine, + }); + return; + } + + const result = await runAction(action); + sendJson(res, result.ok ? 200 : 500, { + ...result, + commandLine: action.commandLine, + }); + return; + } + + sendJson(res, 404, { ok: false, error: 'not found' }); + } catch (error) { + sendJson(res, 500, { + ok: false, + error: error.message, + }); + } + }); + + return { + get url() { + const address = server.address(); + const actualPort = address && typeof address === 'object' ? address.port : port; + return `http://${host}:${actualPort}`; + }, + server, + config: resolvedConfig, + listen() { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.off('error', reject); + resolve(this); + }); + }); + }, + close() { + return new Promise((resolve, reject) => { + server.close(error => { + if (error) reject(error); + else resolve(); + }); + }); + }, + }; +} + +module.exports = { + createControlPaneServer, + parseArgs, + runAction, + usage, +}; diff --git a/scripts/lib/control-pane/state.js b/scripts/lib/control-pane/state.js new file mode 100644 index 00000000..fd742265 --- /dev/null +++ b/scripts/lib/control-pane/state.js @@ -0,0 +1,518 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const initSqlJs = require('sql.js'); +const toml = require('@iarna/toml'); + +const { buildControlPaneActions } = require('./actions'); + +const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1'; + +function homeDir(env = process.env) { + return env.HOME || env.USERPROFILE || os.homedir() || '.'; +} + +function defaultDbPath(env = process.env) { + return path.join(homeDir(env), '.claude', 'ecc2.db'); +} + +function defaultConfigPaths(cwd = process.cwd(), env = process.env) { + const home = homeDir(env); + const paths = [ + path.join(home, 'Library', 'Application Support', 'ecc2', 'config.toml'), + path.join(home, '.config', 'ecc2', 'config.toml'), + path.join(home, '.claude', 'ecc2.toml'), + ]; + + let current = path.resolve(cwd); + while (current && current !== path.dirname(current)) { + paths.push(path.join(current, '.claude', 'ecc2.toml')); + paths.push(path.join(current, 'ecc2.toml')); + current = path.dirname(current); + } + + return Array.from(new Set(paths)); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function deepMerge(base, override) { + const merged = { ...base }; + for (const [key, value] of Object.entries(override || {})) { + if (isPlainObject(value) && isPlainObject(merged[key])) { + merged[key] = deepMerge(merged[key], value); + } else { + merged[key] = value; + } + } + return merged; +} + +function toCamelCase(value) { + return String(value).replace(/_([a-z])/g, (_, char) => char.toUpperCase()); +} + +function normalizeObjectKeys(value) { + if (Array.isArray(value)) return value.map(normalizeObjectKeys); + if (!isPlainObject(value)) return value; + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [toCamelCase(key), normalizeObjectKeys(item)]) + ); +} + +function normalizeMemoryConnectors(connectors = {}) { + return Object.fromEntries( + Object.entries(connectors || {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, connector]) => [name, normalizeObjectKeys(connector)]) + ); +} + +function normalizeConfig(rawConfig = {}, options = {}) { + const { memory_connectors: snakeMemoryConnectors, memoryConnectors, ...rest } = rawConfig; + const normalized = normalizeObjectKeys(rest); + const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized.memoryConnectors; + return { + dbPath: options.dbPath || normalized.dbPath || defaultDbPath(options.env), + memoryConnectors: normalizeMemoryConnectors(connectorConfig), + }; +} + +function readTomlConfig(configPath) { + const raw = fs.readFileSync(configPath, 'utf8'); + return toml.parse(raw); +} + +function resolveControlPaneConfig(options = {}) { + const env = options.env || process.env; + const cwd = options.cwd || process.cwd(); + const configPaths = options.configPath + ? [path.resolve(options.configPath)] + : defaultConfigPaths(cwd, env); + let merged = {}; + + for (const configPath of configPaths) { + if (fs.existsSync(configPath)) { + merged = deepMerge(merged, readTomlConfig(configPath)); + } + } + + return { + ...normalizeConfig(merged, { + env, + dbPath: options.dbPath || env.ECC2_DB_PATH || null, + }), + configPaths: configPaths.filter(configPath => fs.existsSync(configPath)), + }; +} + +async function openSqlDatabase(dbPath) { + if (!dbPath || !fs.existsSync(dbPath)) return null; + const SQL = await initSqlJs(); + const buffer = fs.readFileSync(dbPath); + return new SQL.Database(buffer); +} + +function execRows(db, sql, params = []) { + const stmt = db.prepare(sql); + try { + stmt.bind(params); + const rows = []; + while (stmt.step()) rows.push(stmt.getAsObject()); + return rows; + } finally { + stmt.free(); + } +} + +function tableExists(db, tableName) { + const rows = execRows( + db, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + [tableName] + ); + return rows.length > 0; +} + +function parseJson(value, fallback) { + if (typeof value !== 'string' || value.trim() === '') return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function toNumber(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function normalizeSession(row, unreadMessages) { + const id = String(row.id || ''); + return { + id, + task: String(row.task || ''), + project: String(row.project || ''), + taskGroup: String(row.task_group || ''), + agentType: String(row.agent_type || ''), + harness: String(row.harness || 'unknown'), + detectedHarnesses: parseJson(row.detected_harnesses_json, []), + workingDir: String(row.working_dir || '.'), + state: String(row.state || 'pending'), + pid: row.pid === null || row.pid === undefined ? null : toNumber(row.pid), + worktree: row.worktree_path + ? { + path: String(row.worktree_path), + branch: row.worktree_branch ? String(row.worktree_branch) : null, + base: row.worktree_base ? String(row.worktree_base) : null, + } + : null, + metrics: { + inputTokens: toNumber(row.input_tokens), + outputTokens: toNumber(row.output_tokens), + tokensUsed: toNumber(row.tokens_used), + toolCalls: toNumber(row.tool_calls), + filesChanged: toNumber(row.files_changed), + durationSecs: toNumber(row.duration_secs), + costUsd: toNumber(row.cost_usd), + }, + unreadMessages: unreadMessages.get(id) || 0, + createdAt: String(row.created_at || ''), + updatedAt: String(row.updated_at || ''), + lastHeartbeatAt: String(row.last_heartbeat_at || ''), + }; +} + +function readUnreadMessageCounts(db) { + if (!tableExists(db, 'messages')) return new Map(); + return new Map( + execRows( + db, + 'SELECT to_session, COUNT(*) AS unread_count FROM messages WHERE read = 0 GROUP BY to_session' + ).map(row => [String(row.to_session), toNumber(row.unread_count)]) + ); +} + +function readSessions(db) { + if (!tableExists(db, 'sessions')) return []; + const unreadMessages = readUnreadMessageCounts(db); + return execRows( + db, + `SELECT * + FROM sessions + ORDER BY updated_at DESC, created_at DESC, id ASC + LIMIT 100` + ).map(row => normalizeSession(row, unreadMessages)); +} + +function summarizeSessions(sessions) { + const summary = { + totalSessions: sessions.length, + runningSessions: 0, + pendingSessions: 0, + idleSessions: 0, + failedSessions: 0, + stoppedSessions: 0, + completedSessions: 0, + unreadMessages: 0, + activeWorktrees: 0, + totalTokens: 0, + totalCostUsd: 0, + }; + + for (const session of sessions) { + if (session.state === 'running') summary.runningSessions += 1; + if (session.state === 'pending') summary.pendingSessions += 1; + if (session.state === 'idle') summary.idleSessions += 1; + if (session.state === 'failed') summary.failedSessions += 1; + if (session.state === 'stopped') summary.stoppedSessions += 1; + if (session.state === 'completed') summary.completedSessions += 1; + if (session.worktree) summary.activeWorktrees += 1; + summary.unreadMessages += session.unreadMessages; + summary.totalTokens += session.metrics.tokensUsed; + summary.totalCostUsd += session.metrics.costUsd; + } + + summary.totalCostUsd = Number(summary.totalCostUsd.toFixed(6)); + return summary; +} + +function readEntities(db) { + if (!tableExists(db, 'context_graph_entities')) return []; + return execRows( + db, + `SELECT * + FROM context_graph_entities + ORDER BY updated_at DESC, id DESC + LIMIT 500` + ).map(row => ({ + id: toNumber(row.id), + sessionId: row.session_id ? String(row.session_id) : null, + entityType: String(row.entity_type || ''), + name: String(row.name || ''), + path: row.path ? String(row.path) : null, + summary: String(row.summary || ''), + metadata: parseJson(row.metadata_json, {}), + createdAt: String(row.created_at || ''), + updatedAt: String(row.updated_at || ''), + })); +} + +function readObservations(db) { + if (!tableExists(db, 'context_graph_observations')) return []; + return execRows( + db, + `SELECT * + FROM context_graph_observations + ORDER BY created_at DESC, id DESC + LIMIT 1000` + ).map(row => ({ + id: toNumber(row.id), + sessionId: row.session_id ? String(row.session_id) : null, + entityId: toNumber(row.entity_id), + observationType: String(row.observation_type || ''), + priority: toNumber(row.priority, 1), + pinned: toNumber(row.pinned) === 1, + summary: String(row.summary || ''), + details: parseJson(row.details_json, {}), + createdAt: String(row.created_at || ''), + })); +} + +function readRelationCounts(db) { + if (!tableExists(db, 'context_graph_relations')) return new Map(); + const rows = execRows( + db, + `SELECT entity_id, SUM(relation_count) AS relation_count + FROM ( + SELECT from_entity_id AS entity_id, COUNT(*) AS relation_count + FROM context_graph_relations + GROUP BY from_entity_id + UNION ALL + SELECT to_entity_id AS entity_id, COUNT(*) AS relation_count + FROM context_graph_relations + GROUP BY to_entity_id + ) + GROUP BY entity_id` + ); + return new Map(rows.map(row => [toNumber(row.entity_id), toNumber(row.relation_count)])); +} + +function tokenize(value) { + return String(value || '') + .toLowerCase() + .split(/[^a-z0-9_.-]+/g) + .map(token => token.trim()) + .filter(token => token.length >= 2); +} + +function scoreEntity(entity, observations, relationCount, queryTerms) { + const observationText = observations.map(observation => observation.summary).join(' '); + const metadataText = Object.entries(entity.metadata || {}) + .map(([key, value]) => `${key} ${value}`) + .join(' '); + const haystacks = [ + { text: entity.name, weight: 12 }, + { text: entity.entityType, weight: 5 }, + { text: entity.path || '', weight: 6 }, + { text: entity.summary, weight: 8 }, + { text: metadataText, weight: 5 }, + { text: observationText, weight: 10 }, + ].map(item => ({ ...item, text: item.text.toLowerCase() })); + const matchedTerms = []; + let score = 0; + + for (const term of queryTerms) { + let matched = false; + for (const haystack of haystacks) { + if (haystack.text.includes(term)) { + score += haystack.weight; + matched = true; + } + } + if (matched) matchedTerms.push(term); + } + + const maxPriority = observations.reduce( + (highest, observation) => Math.max(highest, observation.priority), + 0 + ); + const hasPinnedObservation = observations.some(observation => observation.pinned); + score += Math.min(relationCount, 8); + score += maxPriority * 3; + if (hasPinnedObservation) score += 8; + + return { + score, + matchedTerms, + observationCount: observations.length, + relationCount, + maxObservationPriority: maxPriority, + hasPinnedObservation, + }; +} + +function recallKnowledgeEntries({ entities, observations, relationCounts, query, limit = 12 }) { + const queryTerms = Array.from(new Set(tokenize(query))); + const observationsByEntity = new Map(); + for (const observation of observations) { + const bucket = observationsByEntity.get(observation.entityId) || []; + bucket.push(observation); + observationsByEntity.set(observation.entityId, bucket); + } + + return entities + .map(entity => { + const entityObservations = observationsByEntity.get(entity.id) || []; + const score = queryTerms.length > 0 + ? scoreEntity(entity, entityObservations, relationCounts.get(entity.id) || 0, queryTerms) + : { + score: entityObservations.some(observation => observation.pinned) ? 10 : 1, + matchedTerms: [], + observationCount: entityObservations.length, + relationCount: relationCounts.get(entity.id) || 0, + maxObservationPriority: entityObservations.reduce( + (highest, observation) => Math.max(highest, observation.priority), + 0 + ), + hasPinnedObservation: entityObservations.some(observation => observation.pinned), + }; + return { + entity, + ...score, + latestObservation: entityObservations[0] || null, + }; + }) + .filter(entry => queryTerms.length === 0 || entry.matchedTerms.length > 0) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return String(right.entity.updatedAt).localeCompare(String(left.entity.updatedAt)); + }) + .slice(0, Math.max(1, Math.min(Number(limit) || 12, 50))); +} + +function readConnectorCheckpointRows(db) { + if (!tableExists(db, 'context_graph_connector_checkpoints')) return []; + return execRows( + db, + `SELECT connector_name, COUNT(*) AS synced_sources, MAX(updated_at) AS last_synced_at + FROM context_graph_connector_checkpoints + GROUP BY connector_name` + ); +} + +function connectorStatus(config, db) { + const checkpoints = new Map( + (db ? readConnectorCheckpointRows(db) : []).map(row => [ + String(row.connector_name), + { + syncedSources: toNumber(row.synced_sources), + lastSyncedAt: row.last_synced_at ? String(row.last_synced_at) : null, + }, + ]) + ); + + return Object.entries(config.memoryConnectors || {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, connector]) => { + const checkpoint = checkpoints.get(name) || { syncedSources: 0, lastSyncedAt: null }; + return { + name, + kind: connector.kind || 'unknown', + path: connector.path || null, + recurse: Boolean(connector.recurse), + defaultEntityType: connector.defaultEntityType || null, + defaultObservationType: connector.defaultObservationType || null, + includeSafeValues: Boolean(connector.includeSafeValues), + syncedSources: checkpoint.syncedSources, + lastSyncedAt: checkpoint.lastSyncedAt, + }; + }); +} + +async function buildControlPaneSnapshot(options = {}) { + const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..')); + const config = options.config + ? normalizeConfig(options.config, { + env: options.env || process.env, + dbPath: options.dbPath || options.config.dbPath || null, + }) + : resolveControlPaneConfig(options); + const dbPath = options.dbPath || config.dbPath; + const query = String(options.query || '').trim(); + const limit = Math.max(1, Math.min(Number.parseInt(String(options.limit || 12), 10) || 12, 50)); + const generatedAt = new Date().toISOString(); + const base = { + schemaVersion: SNAPSHOT_SCHEMA_VERSION, + generatedAt, + repoRoot, + dbPath, + database: { + exists: Boolean(dbPath && fs.existsSync(dbPath)), + }, + config: { + configPaths: config.configPaths || [], + memoryConnectorCount: Object.keys(config.memoryConnectors || {}).length, + }, + execution: { + allowActions: options.allowActions !== false, + }, + summary: summarizeSessions([]), + sessions: [], + knowledge: { + query, + entityCount: 0, + observationCount: 0, + results: [], + }, + connectors: connectorStatus(config, null), + actions: buildControlPaneActions({ repoRoot, query, limit }), + }; + + const db = await openSqlDatabase(dbPath); + if (!db) { + return base; + } + + try { + const sessions = readSessions(db); + const entities = readEntities(db); + const observations = readObservations(db); + const relationCounts = readRelationCounts(db); + return { + ...base, + summary: summarizeSessions(sessions), + sessions, + knowledge: { + query, + entityCount: entities.length, + observationCount: observations.length, + results: recallKnowledgeEntries({ + entities, + observations, + relationCounts, + query, + limit, + }), + }, + connectors: connectorStatus(config, db), + }; + } finally { + db.close(); + } +} + +module.exports = { + SNAPSHOT_SCHEMA_VERSION, + buildControlPaneSnapshot, + defaultConfigPaths, + recallKnowledgeEntries, + resolveControlPaneConfig, +}; diff --git a/scripts/lib/control-pane/ui.js b/scripts/lib/control-pane/ui.js new file mode 100644 index 00000000..f8aae61c --- /dev/null +++ b/scripts/lib/control-pane/ui.js @@ -0,0 +1,554 @@ +'use strict'; + +function renderControlPaneHtml() { + return ` + + + + + ECC Control Pane + + + +
+
+
+ +

ECC Control Pane

+
+
+ + + +
+
+
+
+
+
+
+

Sessions

+ +
+
+
+
+
+
+
+

Knowledge

+ +
+
+
+
+
+

Connectors

+ +
+
+
+
+
+

Actions

+ local allowlist +
+
+
+ No action output yet. +
+
+
+
+
+ + + +`; +} + +module.exports = { + renderControlPaneHtml, +}; diff --git a/tests/lib/control-pane-actions.test.js b/tests/lib/control-pane-actions.test.js new file mode 100644 index 00000000..0d434cc2 --- /dev/null +++ b/tests/lib/control-pane-actions.test.js @@ -0,0 +1,100 @@ +/** + * Tests for allowlisted ECC2 control-pane actions. + */ + +const assert = require('assert'); +const path = require('path'); + +const { + buildControlPaneActions, + buildControlPaneAction, + shellQuote, +} = require('../../scripts/lib/control-pane/actions'); + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing control-pane actions ===\n'); + + let passed = 0; + let failed = 0; + + if (test('builds copyable and executable allowlisted ECC2 actions', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const actions = buildControlPaneActions({ + repoRoot, + query: 'Hermes Desktop Zellij', + limit: 25, + }); + + assert.ok(actions.some(action => action.id === 'sync-knowledge')); + assert.ok(actions.some(action => action.id === 'recall-knowledge')); + assert.ok(actions.some(action => action.id === 'open-dashboard')); + + const sync = actions.find(action => action.id === 'sync-knowledge'); + assert.strictEqual(sync.executable, true); + assert.strictEqual(sync.command, 'cargo'); + assert.deepStrictEqual(sync.args, [ + 'run', + '--quiet', + '--', + 'graph', + 'connector-sync', + '--all', + '--json', + '--limit', + '25', + ]); + assert.strictEqual(sync.cwd, path.join(repoRoot, 'ecc2')); + assert.ok(sync.commandLine.includes('connector-sync')); + })) passed++; else failed++; + + if (test('preserves recall query as a single argument instead of shell text', () => { + const action = buildControlPaneAction('recall-knowledge', { + repoRoot: '/repo/ecc', + query: 'Hermes "Desktop"; rm -rf ~', + limit: 7, + }); + + assert.deepStrictEqual(action.args, [ + 'run', + '--quiet', + '--', + 'graph', + 'recall', + 'Hermes "Desktop"; rm -rf ~', + '--json', + '--limit', + '7', + ]); + assert.ok(action.commandLine.includes("'Hermes \"Desktop\"; rm -rf ~'")); + })) passed++; else failed++; + + if (test('rejects unknown action identifiers', () => { + assert.throws( + () => buildControlPaneAction('rm -rf', { repoRoot: '/repo/ecc' }), + /Unknown control-pane action/ + ); + })) passed++; else failed++; + + if (test('shellQuote handles empty strings and single quotes', () => { + assert.strictEqual(shellQuote(''), "''"); + assert.strictEqual(shellQuote("can't"), "'can'\\''t'"); + assert.strictEqual(shellQuote('simple'), 'simple'); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/control-pane-state.test.js b/tests/lib/control-pane-state.test.js new file mode 100644 index 00000000..c535e70d --- /dev/null +++ b/tests/lib/control-pane-state.test.js @@ -0,0 +1,663 @@ +/** + * Tests for the local ECC2 control-pane state projection. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const initSqlJs = require('sql.js'); + +const { + buildControlPaneSnapshot, + recallKnowledgeEntries, + resolveControlPaneConfig, +} = require('../../scripts/lib/control-pane/state'); + +async function test(name, fn) { + try { + await fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +async function writeSampleEcc2Database(dbPath) { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + + db.run(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + task TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + task_group TEXT NOT NULL DEFAULT '', + agent_type TEXT NOT NULL, + harness TEXT NOT NULL DEFAULT 'unknown', + detected_harnesses_json TEXT NOT NULL DEFAULT '[]', + working_dir TEXT NOT NULL DEFAULT '.', + state TEXT NOT NULL DEFAULT 'pending', + pid INTEGER, + worktree_path TEXT, + worktree_branch TEXT, + worktree_base TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + tokens_used INTEGER DEFAULT 0, + tool_calls INTEGER DEFAULT 0, + files_changed INTEGER DEFAULT 0, + duration_secs INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0.0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_heartbeat_at TEXT NOT NULL + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_session TEXT NOT NULL, + to_session TEXT NOT NULL, + content TEXT NOT NULL, + msg_type TEXT NOT NULL DEFAULT 'info', + read INTEGER DEFAULT 0, + timestamp TEXT NOT NULL + ); + CREATE TABLE context_graph_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + entity_key TEXT NOT NULL UNIQUE, + entity_type TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT, + summary TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE context_graph_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + entity_id INTEGER NOT NULL, + observation_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 1, + pinned INTEGER NOT NULL DEFAULT 0, + summary TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE TABLE context_graph_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + from_entity_id INTEGER NOT NULL, + to_entity_id INTEGER NOT NULL, + relation_type TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + CREATE TABLE context_graph_connector_checkpoints ( + connector_name TEXT NOT NULL, + source_path TEXT NOT NULL, + source_signature TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (connector_name, source_path) + ); + `); + + const insertSession = db.prepare(` + INSERT INTO sessions ( + id, task, project, task_group, agent_type, harness, detected_harnesses_json, + working_dir, state, pid, worktree_path, worktree_branch, worktree_base, + input_tokens, output_tokens, tokens_used, tool_calls, files_changed, + duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertSession.run([ + 'lead-hermes', + 'Coordinate Hermes desktop and ECC release work', + 'ECC', + '2.0-control-pane', + 'claude', + 'claude', + JSON.stringify(['claude', 'codex']), + '/repo/ecc', + 'running', + 4242, + '/tmp/ecc-worktrees/hermes', + 'ecc/hermes-control-pane', + 'main', + 1200, + 800, + 2000, + 19, + 6, + 540, + 0.42, + '2026-06-03T10:00:00Z', + '2026-06-03T10:15:00Z', + '2026-06-03T10:15:00Z', + ]); + insertSession.run([ + 'worker-kb', + 'Index operator memory', + 'ECC', + 'knowledge', + 'codex', + 'codex', + JSON.stringify(['codex']), + '/repo/ecc', + 'idle', + null, + null, + null, + null, + 300, + 200, + 500, + 4, + 2, + 120, + 0.07, + '2026-06-03T10:05:00Z', + '2026-06-03T10:14:00Z', + '2026-06-03T10:14:00Z', + ]); + insertSession.free(); + + db.run( + 'INSERT INTO messages (from_session, to_session, content, msg_type, read, timestamp) VALUES (?, ?, ?, ?, ?, ?)', + ['worker-kb', 'lead-hermes', 'Need approval for connector sync', 'approval_request', 0, '2026-06-03T10:16:00Z'] + ); + + const insertEntity = db.prepare(` + INSERT INTO context_graph_entities ( + session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertEntity.run([ + 'lead-hermes', + 'runbook:Hermes revenue runbook:/notes/hermes.md', + 'runbook', + 'Hermes revenue runbook', + '/notes/hermes.md', + 'How Affaan routes Hermes Desktop, Zellij panes, Devin-style delegation, and ECC release control work.', + JSON.stringify({ source: 'hermes_workspace', platform: 'desktop' }), + '2026-06-03T10:10:00Z', + '2026-06-03T10:10:00Z', + ]); + insertEntity.run([ + null, + 'concept:gbrain memory:/notes/gbrain.md', + 'concept', + 'gbrain memory', + '/notes/gbrain.md', + 'Operator knowledge base pattern for cross-platform agent memory.', + JSON.stringify({ source: 'workspace_notes' }), + '2026-06-03T10:11:00Z', + '2026-06-03T10:11:00Z', + ]); + insertEntity.free(); + + db.run( + 'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + 'lead-hermes', + 1, + 'operator_memory', + 3, + 1, + 'Hermes Desktop and ECC should share recall before dispatching work.', + JSON.stringify({ note: 'safe public summary only' }), + '2026-06-03T10:12:00Z', + ] + ); + db.run( + 'INSERT INTO context_graph_relations (session_id, from_entity_id, to_entity_id, relation_type, summary, created_at) VALUES (?, ?, ?, ?, ?, ?)', + ['lead-hermes', 1, 2, 'depends_on', 'Runbook uses durable memory concepts.', '2026-06-03T10:13:00Z'] + ); + db.run( + 'INSERT INTO context_graph_connector_checkpoints (connector_name, source_path, source_signature, updated_at) VALUES (?, ?, ?, ?)', + ['hermes_workspace', '/notes/hermes.md', 'sig-1', '2026-06-03T10:12:00Z'] + ); + + fs.writeFileSync(dbPath, Buffer.from(db.export())); + db.close(); +} + +async function mutateSqlDatabase(dbPath, mutator) { + const SQL = await initSqlJs(); + const buffer = fs.readFileSync(dbPath); + const db = new SQL.Database(buffer); + try { + await mutator(db); + fs.writeFileSync(dbPath, Buffer.from(db.export())); + } finally { + db.close(); + } +} + +async function runTests() { + console.log('\n=== Testing control-pane state ===\n'); + + let passed = 0; + let failed = 0; + + if (await test('builds an operator snapshot from ECC2 SQLite and configured connectors', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-state-')); + const dbPath = path.join(tempDir, 'ecc2.db'); + + try { + await writeSampleEcc2Database(dbPath); + const snapshot = await buildControlPaneSnapshot({ + dbPath, + repoRoot: path.join(__dirname, '..', '..'), + query: 'Hermes Desktop Zellij gbrain', + config: { + memoryConnectors: { + hermes_workspace: { + kind: 'markdown_directory', + path: '/notes', + recurse: true, + }, + safe_env: { + kind: 'dotenv_file', + path: '/notes/.env', + includeSafeValues: false, + }, + }, + }, + }); + + assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1'); + assert.strictEqual(snapshot.summary.totalSessions, 2); + assert.strictEqual(snapshot.summary.runningSessions, 1); + assert.strictEqual(snapshot.summary.unreadMessages, 1); + assert.strictEqual(snapshot.sessions[0].id, 'lead-hermes'); + assert.deepStrictEqual(snapshot.sessions[0].detectedHarnesses, ['claude', 'codex']); + assert.strictEqual(snapshot.knowledge.query, 'Hermes Desktop Zellij gbrain'); + assert.strictEqual(snapshot.knowledge.results[0].entity.name, 'Hermes revenue runbook'); + assert.ok(snapshot.knowledge.results[0].matchedTerms.includes('hermes')); + assert.strictEqual(snapshot.knowledge.results[0].hasPinnedObservation, true); + assert.strictEqual(snapshot.connectors.length, 2); + assert.strictEqual(snapshot.connectors[0].name, 'hermes_workspace'); + assert.strictEqual(snapshot.connectors[0].syncedSources, 1); + assert.strictEqual(snapshot.connectors[1].syncedSources, 0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('resolves config from explicit db path and TOML connector file', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-config-')); + const dbPath = path.join(tempDir, 'state.db'); + const configPath = path.join(tempDir, 'ecc2.toml'); + + try { + fs.writeFileSync( + configPath, + [ + `db_path = "${dbPath.replace(/\\/g, '\\\\')}"`, + '', + '[memory_connectors.hermes_workspace]', + 'kind = "markdown_directory"', + 'path = "/tmp/hermes"', + 'recurse = true', + 'default_entity_type = "operator_note"', + ].join('\n'), + 'utf8' + ); + + const config = resolveControlPaneConfig({ + cwd: tempDir, + configPath, + }); + + assert.strictEqual(config.dbPath, dbPath); + assert.strictEqual(config.memoryConnectors.hermes_workspace.kind, 'markdown_directory'); + assert.strictEqual(config.memoryConnectors.hermes_workspace.path, '/tmp/hermes'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('prefers the operator home config over stale app-support config', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-precedence-')); + const homeDir = path.join(tempDir, 'home'); + const homeConfigDir = path.join(homeDir, '.claude'); + const appConfigDir = path.join(homeDir, 'Library', 'Application Support', 'ecc2'); + const homeDbPath = path.join(tempDir, 'operator.db'); + const staleDbPath = path.join(tempDir, 'stale-smoke.db'); + + try { + fs.mkdirSync(homeConfigDir, { recursive: true }); + fs.mkdirSync(appConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(appConfigDir, 'config.toml'), + `db_path = "${staleDbPath.replace(/\\/g, '\\\\')}"\n`, + 'utf8' + ); + fs.writeFileSync( + path.join(homeConfigDir, 'ecc2.toml'), + `db_path = "${homeDbPath.replace(/\\/g, '\\\\')}"\n`, + 'utf8' + ); + + const config = resolveControlPaneConfig({ + cwd: tempDir, + env: { HOME: homeDir }, + }); + + assert.strictEqual(config.dbPath, homeDbPath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('shows configured connectors even when the SQLite database is missing', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-missing-db-')); + + try { + const snapshot = await buildControlPaneSnapshot({ + repoRoot: path.join(__dirname, '..', '..'), + dbPath: path.join(tempDir, 'missing.db'), + config: { + memoryConnectors: { + hermes_workspace: { + kind: 'markdown_directory', + path: '/notes/hermes', + recurse: true, + }, + }, + }, + }); + + assert.strictEqual(snapshot.database.exists, false); + assert.strictEqual(snapshot.connectors.length, 1); + assert.strictEqual(snapshot.connectors[0].name, 'hermes_workspace'); + assert.strictEqual(snapshot.connectors[0].syncedSources, 0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('handles an existing SQLite database before ECC2 tables are created', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-empty-db-')); + const dbPath = path.join(tempDir, 'empty.db'); + + try { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + fs.writeFileSync(dbPath, Buffer.from(db.export())); + db.close(); + + const snapshot = await buildControlPaneSnapshot({ + repoRoot: path.join(__dirname, '..', '..'), + dbPath, + config: { + memoryConnectors: { + workspace_notes: { + kind: 'markdown_directory', + path: '/notes', + includeSafeValues: false, + }, + }, + }, + }); + + assert.strictEqual(snapshot.database.exists, true); + assert.strictEqual(snapshot.summary.totalSessions, 0); + assert.strictEqual(snapshot.knowledge.entityCount, 0); + assert.strictEqual(snapshot.knowledge.observationCount, 0); + assert.strictEqual(snapshot.connectors[0].name, 'workspace_notes'); + assert.strictEqual(snapshot.connectors[0].lastSyncedAt, null); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('recalls pinned knowledge when no query is provided', async () => { + const results = recallKnowledgeEntries({ + entities: [ + { + id: 1, + entityType: 'runbook', + name: 'Pinned runbook', + path: '/notes/pinned.md', + summary: 'Pinned operator context', + metadata: {}, + updatedAt: '2026-06-03T10:00:00Z', + }, + { + id: 2, + entityType: 'concept', + name: 'Unpinned concept', + path: null, + summary: 'Secondary context', + metadata: {}, + updatedAt: '2026-06-03T11:00:00Z', + }, + ], + observations: [ + { + entityId: 1, + priority: 4, + pinned: true, + summary: 'Pinned detail', + }, + { + entityId: 2, + priority: 2, + pinned: false, + summary: 'Other detail', + }, + ], + relationCounts: new Map([[1, 3]]), + query: '', + limit: 0, + }); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].entity.name, 'Pinned runbook'); + assert.strictEqual(results[0].hasPinnedObservation, true); + assert.strictEqual(results[0].relationCount, 3); + assert.strictEqual(results[1].entity.name, 'Unpinned concept'); + })) passed++; else failed++; + + if (await test('handles malformed JSON rows and all session state counters', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-edge-db-')); + const dbPath = path.join(tempDir, 'ecc2.db'); + + try { + await writeSampleEcc2Database(dbPath); + await mutateSqlDatabase(dbPath, db => { + const insertSession = db.prepare(` + INSERT INTO sessions ( + id, task, project, task_group, agent_type, harness, detected_harnesses_json, + working_dir, state, pid, worktree_path, worktree_branch, worktree_base, + input_tokens, output_tokens, tokens_used, tool_calls, files_changed, + duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const state of ['pending', 'failed', 'stopped', 'completed']) { + insertSession.run([ + `session-${state}`, + `Exercise ${state}`, + 'ECC', + 'coverage', + 'codex', + '', + state === 'failed' ? '{bad json' : '[]', + '', + state, + state === 'pending' ? 'not-a-pid' : null, + state === 'completed' ? '/tmp/worktree' : null, + null, + null, + 'not-input-tokens', + null, + state === 'pending' ? 'not-tokens' : 10, + null, + null, + null, + state === 'failed' ? 'not-cost' : 0.1, + '2026-06-03T11:00:00Z', + `2026-06-03T11:0${state.length % 10}:00Z`, + '', + ]); + } + insertSession.free(); + + db.run( + `INSERT INTO context_graph_entities ( + session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'session-failed', + 'bad:json', + 'note', + 'Malformed JSON knowledge', + '/notes/malformed.md', + 'This record should still be searchable.', + '{bad json', + '2026-06-03T11:20:00Z', + '2026-06-03T11:20:00Z', + ] + ); + db.run( + 'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + 'session-failed', + 3, + '', + 'not-a-priority', + 0, + 'Malformed details should fall back safely.', + '{bad json', + '2026-06-03T11:21:00Z', + ] + ); + }); + + const snapshot = await buildControlPaneSnapshot({ + repoRoot: path.join(__dirname, '..', '..'), + dbPath, + query: 'Malformed', + config: { + memoryConnectors: { + malformed_notes: { + kind: 'markdown_directory', + path: '/notes/malformed', + recurse: false, + defaultEntityType: 'note', + defaultObservationType: 'operator_memory', + includeSafeValues: true, + }, + }, + }, + }); + + assert.strictEqual(snapshot.summary.pendingSessions, 1); + assert.strictEqual(snapshot.summary.failedSessions, 1); + assert.strictEqual(snapshot.summary.stoppedSessions, 1); + assert.strictEqual(snapshot.summary.completedSessions, 1); + assert.strictEqual(snapshot.summary.runningSessions, 1); + assert.strictEqual(snapshot.summary.idleSessions, 1); + assert.strictEqual(snapshot.summary.totalSessions, 6); + + const failedSession = snapshot.sessions.find(session => session.id === 'session-failed'); + assert.deepStrictEqual(failedSession.detectedHarnesses, []); + assert.strictEqual(failedSession.metrics.costUsd, 0); + + const pendingSession = snapshot.sessions.find(session => session.id === 'session-pending'); + assert.strictEqual(pendingSession.pid, 0); + assert.strictEqual(pendingSession.metrics.tokensUsed, 0); + + assert.strictEqual(snapshot.knowledge.results[0].entity.name, 'Malformed JSON knowledge'); + assert.deepStrictEqual(snapshot.knowledge.results[0].entity.metadata, {}); + assert.deepStrictEqual(snapshot.knowledge.results[0].latestObservation.details, {}); + assert.strictEqual(snapshot.connectors[0].defaultEntityType, 'note'); + assert.strictEqual(snapshot.connectors[0].defaultObservationType, 'operator_memory'); + assert.strictEqual(snapshot.connectors[0].includeSafeValues, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('recall search covers metadata, relation caps, no matches, and tie ordering', async () => { + const baseEntities = [ + { + id: 1, + entityType: 'note', + name: 'First shared memory', + path: '/notes/shared-a.md', + summary: 'Platform context', + metadata: { source: 'workspace' }, + updatedAt: '2026-06-03T10:00:00Z', + }, + { + id: 2, + entityType: 'note', + name: 'Second shared memory', + path: '/notes/shared-b.md', + summary: 'Platform context', + metadata: { source: 'workspace' }, + updatedAt: '2026-06-03T12:00:00Z', + }, + { + id: 3, + entityType: 'concept', + name: 'Markets graph', + path: null, + summary: 'Correlation graph visualization', + metadata: { flow: 'friction-flow' }, + updatedAt: '2026-06-03T09:00:00Z', + }, + ]; + const observations = [ + { + entityId: 3, + priority: 1, + pinned: false, + summary: 'Ito should expose market backtesting through ECC tools.', + }, + ]; + + const tied = recallKnowledgeEntries({ + entities: baseEntities, + observations: [], + relationCounts: new Map(), + query: 'shared', + limit: 50, + }); + assert.deepStrictEqual(tied.map(entry => entry.entity.id), [2, 1]); + + const metadataHit = recallKnowledgeEntries({ + entities: baseEntities, + observations, + relationCounts: new Map([[3, 20]]), + query: 'friction-flow backtesting', + limit: -5, + }); + assert.strictEqual(metadataHit.length, 1); + assert.strictEqual(metadataHit[0].entity.id, 3); + assert.strictEqual(metadataHit[0].relationCount, 20); + assert.ok(metadataHit[0].score >= 18); + + const noHits = recallKnowledgeEntries({ + entities: baseEntities, + observations, + relationCounts: new Map(), + query: 'unmatched', + limit: 'wat', + }); + assert.deepStrictEqual(noHits, []); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/control-pane.test.js b/tests/scripts/control-pane.test.js new file mode 100644 index 00000000..e7e734f5 --- /dev/null +++ b/tests/scripts/control-pane.test.js @@ -0,0 +1,423 @@ +/** + * Tests for scripts/control-pane.js and its local HTTP API. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const initSqlJs = require('sql.js'); + +const { + createControlPaneServer, + parseArgs, + runAction, +} = require('../../scripts/lib/control-pane/server'); +const { + main: runControlPaneCli, +} = require('../../scripts/control-pane'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'control-pane.js'); +const REPO_ROOT = path.join(__dirname, '..', '..'); + +async function test(name, fn) { + try { + await fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +async function writeMinimalDatabase(dbPath) { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + task TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + task_group TEXT NOT NULL DEFAULT '', + agent_type TEXT NOT NULL, + harness TEXT NOT NULL DEFAULT 'unknown', + detected_harnesses_json TEXT NOT NULL DEFAULT '[]', + working_dir TEXT NOT NULL DEFAULT '.', + state TEXT NOT NULL DEFAULT 'pending', + pid INTEGER, + worktree_path TEXT, + worktree_branch TEXT, + worktree_base TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + tokens_used INTEGER DEFAULT 0, + tool_calls INTEGER DEFAULT 0, + files_changed INTEGER DEFAULT 0, + duration_secs INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0.0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_heartbeat_at TEXT NOT NULL + ); + INSERT INTO sessions ( + id, task, agent_type, harness, detected_harnesses_json, working_dir, state, + created_at, updated_at, last_heartbeat_at + ) VALUES ( + 'session-a', 'Build the control pane', 'codex', 'codex', '["codex"]', '/repo/ecc', + 'running', '2026-06-03T10:00:00Z', '2026-06-03T10:05:00Z', '2026-06-03T10:05:00Z' + ); + `); + fs.writeFileSync(dbPath, Buffer.from(db.export())); + db.close(); +} + +function waitForCliReady(child) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGTERM'); + reject(new Error(`Timed out waiting for control pane CLI.\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, 5000); + + child.stdout.on('data', chunk => { + stdout += chunk.toString('utf8'); + if (!settled && stdout.includes('ECC Control Pane:') && stdout.includes('Actions:')) { + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr }); + } + }); + child.stderr.on('data', chunk => { + stderr += chunk.toString('utf8'); + }); + child.on('error', error => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + child.on('exit', code => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`control pane CLI exited early with ${code}.\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }); + }); +} + +function waitForExit(child) { + return new Promise(resolve => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); +} + +async function fetchLocal(url, options) { + let lastError; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return await fetch(url, options); + } catch (error) { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1))); + } + } + throw lastError; +} + +async function runTests() { + console.log('\n=== Testing control-pane server ===\n'); + + let passed = 0; + let failed = 0; + + if (await test('parses CLI arguments for local-only serving', async () => { + const parsed = parseArgs([ + 'node', + 'scripts/control-pane.js', + '--host', + '127.0.0.1', + '--port', + '8788', + '--db', + '/tmp/ecc2.db', + '--query', + 'Hermes memory', + '--no-open', + ]); + + assert.strictEqual(parsed.host, '127.0.0.1'); + assert.strictEqual(parsed.port, 8788); + assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db'); + assert.strictEqual(parsed.query, 'Hermes memory'); + assert.strictEqual(parsed.openBrowser, false); + })) passed++; else failed++; + + if (await test('rejects invalid CLI port values', async () => { + assert.throws( + () => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), + /Invalid --port value/ + ); + assert.throws( + () => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), + /Invalid --port value/ + ); + })) passed++; else failed++; + + if (await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-')); + const dbPath = path.join(tempDir, 'ecc2.db'); + + try { + await writeMinimalDatabase(dbPath); + const app = await createControlPaneServer({ + host: '127.0.0.1', + port: 0, + dbPath, + repoRoot: REPO_ROOT, + query: 'control pane', + allowActions: false, + }); + + await app.listen(); + try { + const html = await fetchLocal(`${app.url}/`).then(response => response.text()); + assert.ok(html.includes('ECC Control Pane')); + assert.ok(html.includes('id="app"')); + assert.ok(html.includes('function showError')); + assert.ok(html.includes('response.ok')); + + const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json()); + assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1'); + assert.strictEqual(snapshot.summary.totalSessions, 1); + assert.strictEqual(snapshot.sessions[0].id, 'session-a'); + } finally { + await app.close(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-')); + + try { + const app = await createControlPaneServer({ + host: '127.0.0.1', + port: 0, + dbPath: path.join(tempDir, 'missing.db'), + repoRoot: tempDir, + allowActions: false, + }); + + await app.listen(); + try { + const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json()); + assert.strictEqual(health.ok, true); + assert.strictEqual(health.allowActions, false); + + const realAssetApp = await createControlPaneServer({ + host: '127.0.0.1', + port: 0, + dbPath: path.join(tempDir, 'missing.db'), + repoRoot: REPO_ROOT, + allowActions: false, + }); + await realAssetApp.listen(); + try { + const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`); + assert.strictEqual(realAsset.status, 200); + assert.match(await realAsset.text(), / response.json()); + assert.strictEqual(missing.ok, false); + assert.strictEqual(missing.error, 'not found'); + + const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: 'memory' }), + }).then(async response => ({ status: response.status, body: await response.json() })); + assert.strictEqual(blocked.status, 403); + assert.match(blocked.body.error, /disabled/); + + const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{bad json', + }).then(async response => ({ status: response.status, body: await response.json() })); + assert.strictEqual(invalidBody.status, 403); + } finally { + await app.close(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (await test('guards copy-only and unknown action requests', async () => { + const app = await createControlPaneServer({ + host: '127.0.0.1', + port: 0, + repoRoot: REPO_ROOT, + allowActions: true, + }); + + await app.listen(); + try { + const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }).then(async response => ({ status: response.status, body: await response.json() })); + assert.strictEqual(copyOnly.status, 400); + assert.strictEqual(copyOnly.body.action, 'open-dashboard'); + assert.match(copyOnly.body.error, /copy-only/); + + const unknown = await fetchLocal(`${app.url}/api/actions/nope`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }).then(async response => ({ status: response.status, body: await response.json() })); + assert.strictEqual(unknown.status, 500); + assert.match(unknown.body.error, /Unknown control-pane action/); + + const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{bad json', + }).then(async response => ({ status: response.status, body: await response.json() })); + assert.strictEqual(invalidBody.status, 500); + assert.match(invalidBody.body.error, /JSON/); + } finally { + await app.close(); + } + })) passed++; else failed++; + + if (await test('runAction captures success, failure, and bounded output', async () => { + const repoRoot = REPO_ROOT; + const success = await runAction({ + id: 'node-success', + command: process.execPath, + args: ['-e', 'process.stdout.write("x".repeat(21010))'], + cwd: repoRoot, + }); + assert.strictEqual(success.ok, true); + assert.strictEqual(success.code, 0); + assert.ok(success.stdout.includes('[truncated ')); + + const failure = await runAction({ + id: 'node-failure', + command: process.execPath, + args: ['-e', 'process.stderr.write("bad"); process.exit(7)'], + cwd: repoRoot, + }); + assert.strictEqual(failure.ok, false); + assert.strictEqual(failure.code, 7); + assert.strictEqual(failure.stderr, 'bad'); + + const spawnError = await runAction({ + id: 'spawn-error', + command: 'definitely-not-ecc-control-pane-command', + args: [], + cwd: repoRoot, + }); + assert.strictEqual(spawnError.ok, false); + assert.strictEqual(spawnError.code, null); + assert.match(spawnError.error, /ENOENT/); + })) passed++; else failed++; + + if (await test('runAction terminates commands that exceed the local timeout', async () => { + const timedOut = await runAction( + { + id: 'node-timeout', + command: process.execPath, + args: ['-e', 'setTimeout(() => {}, 5000)'], + cwd: REPO_ROOT, + }, + { timeoutMs: 25 } + ); + + assert.strictEqual(timedOut.ok, false); + assert.strictEqual(timedOut.signal, 'SIGTERM'); + })) passed++; else failed++; + + if (await test('CLI prints help', async () => { + const result = spawnSync('node', [SCRIPT, '--help'], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(result.stdout.includes('Usage:')); + assert.ok(result.stdout.includes('control-pane')); + })) passed++; else failed++; + + if (await test('CLI browser opener handles spawn errors', async () => { + const source = fs.readFileSync(SCRIPT, 'utf8'); + + assert.match(source, /child\.on\('error'/); + assert.match(source, /child\.unref\(\)/); + })) passed++; else failed++; + + if (await test('CLI main handles help without starting a server', async () => { + const originalLog = console.log; + const lines = []; + console.log = line => { + lines.push(String(line)); + }; + try { + await runControlPaneCli(['node', 'scripts/control-pane.js', '--help']); + } finally { + console.log = originalLog; + } + + assert.match(lines.join('\n'), /Usage:/); + assert.match(lines.join('\n'), /--read-only/); + })) passed++; else failed++; + + if (await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => { + const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db'), + }, + }); + const exitPromise = waitForExit(child); + + try { + const ready = await waitForCliReady(child); + assert.match(ready.stdout, /ECC Control Pane: http:\/\/127\.0\.0\.1:\d+/); + assert.match(ready.stdout, /Actions: read-only/); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + const result = await exitPromise; + assert.ok( + result.code === 0 || result.signal === 'SIGTERM', + `expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}` + ); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 7d3d5ad8..b3c757d9 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -70,6 +70,7 @@ function main() { assert.match(result.stdout, /doctor/); assert.match(result.stdout, /auto-update/); assert.match(result.stdout, /consult/); + assert.match(result.stdout, /control-pane/); assert.match(result.stdout, /loop-status/); assert.match(result.stdout, /work-items/); assert.match(result.stdout, /platform-audit/); @@ -114,6 +115,12 @@ function main() { assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1'); assert.strictEqual(payload.matches[0].componentId, 'capability:security'); }], + ['supports help for the control-pane subcommand', () => { + const result = runCli(['help', 'control-pane']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + assert.match(result.stdout, /control-pane/); + }], ['delegates lifecycle commands', () => { const homeDir = createTempDir('ecc-cli-home-'); const projectRoot = createTempDir('ecc-cli-project-'); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 17509d3e..c5cfbc36 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -46,6 +46,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/ci/scan-supply-chain-iocs.js", "scripts/ci/supply-chain-advisory-sources.js", "scripts/consult.js", + "scripts/control-pane.js", "scripts/claw.js", "scripts/discussion-audit.js", "scripts/doctor.js", @@ -132,6 +133,7 @@ function main() { "scripts/ci/scan-supply-chain-iocs.js", "scripts/ci/supply-chain-advisory-sources.js", "scripts/consult.js", + "scripts/control-pane.js", "scripts/discussion-audit.js", "scripts/operator-readiness-dashboard.js", "scripts/preview-pack-smoke.js", diff --git a/yarn.lock b/yarn.lock index eec98bd1..4ad9ef8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,6 +642,7 @@ __metadata: typescript: "npm:^6.0.3" bin: ecc: scripts/ecc.js + ecc-control-pane: scripts/control-pane.js ecc-install: scripts/install-apply.js languageName: unknown linkType: soft