feat: add dynamic workflow team orchestration surface

Adds dynamic workflow/team orchestration skills, the content pack, and control-pane work-item/Kanban state DB support. Includes reviewer hardening for state-db CLI validation, optional state DB failure handling, and mergeStateStatus projection.
This commit is contained in:
Affaan Mustafa
2026-06-04 21:45:13 +08:00
committed by GitHub
parent 0f84c0e279
commit bc8e12bb80
19 changed files with 872 additions and 17 deletions

View File

@@ -34,6 +34,7 @@ async function main(argv = process.argv) {
console.log(`ECC Control Pane: ${app.url}`);
console.log(`ECC2 database: ${app.config.dbPath}`);
console.log(`ECC state database: ${app.config.stateDbPath}`);
console.log(args.allowActions ? 'Actions: enabled for local allowlist' : 'Actions: read-only');
if (args.openBrowser) {

View File

@@ -12,9 +12,10 @@ const { renderControlPaneHtml } = require('./ui');
function usage() {
return [
'Usage:',
' node scripts/control-pane.js [--host 127.0.0.1] [--port 8765] [--db <ecc2.db>] [--config <ecc2.toml>] [--query <text>]',
' node scripts/control-pane.js [--host 127.0.0.1] [--port 8765] [--db <ecc2.db>] [--state-db <state.db>] [--config <ecc2.toml>] [--query <text>]',
'',
'Options:',
' --state-db <path> Read agent work items from an ECC state-store database',
' --read-only Disable action execution endpoints',
' --no-open Do not open a browser after the server starts',
' --help Show this help',
@@ -26,6 +27,15 @@ function valueAfter(args, name) {
return index >= 0 ? args[index + 1] : null;
}
function pathValueAfter(args, name) {
const value = valueAfter(args, name);
if (value === null) return null;
if (!value || value.startsWith('-')) {
throw new Error(`Invalid ${name} value: expected a path`);
}
return value;
}
function parseArgs(argv) {
const args = argv.slice(2);
const help = args.includes('--help') || args.includes('-h');
@@ -41,6 +51,7 @@ function parseArgs(argv) {
host,
port,
dbPath: valueAfter(args, '--db'),
stateDbPath: pathValueAfter(args, '--state-db'),
configPath: valueAfter(args, '--config'),
query: valueAfter(args, '--query') || '',
openBrowser: !args.includes('--no-open'),
@@ -144,6 +155,7 @@ function createControlPaneServer(options = {}) {
cwd: options.cwd || repoRoot,
configPath: options.configPath,
dbPath: options.dbPath,
stateDbPath: options.stateDbPath,
env: options.env || process.env,
});
const baseQuery = options.query || '';
@@ -172,6 +184,7 @@ function createControlPaneServer(options = {}) {
ok: true,
repoRoot,
dbPath: resolvedConfig.dbPath,
stateDbPath: resolvedConfig.stateDbPath,
allowActions,
});
return;
@@ -181,6 +194,7 @@ function createControlPaneServer(options = {}) {
const snapshot = await buildControlPaneSnapshot({
repoRoot,
dbPath: resolvedConfig.dbPath,
stateDbPath: resolvedConfig.stateDbPath,
config: resolvedConfig,
query: requestUrl.searchParams.get('query') || baseQuery,
limit: requestUrl.searchParams.get('limit') || 12,

View File

@@ -10,6 +10,7 @@ const toml = require('@iarna/toml');
const { buildControlPaneActions } = require('./actions');
const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1';
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
function homeDir(env = process.env) {
return env.HOME || env.USERPROFILE || os.homedir() || '.';
@@ -19,6 +20,10 @@ function defaultDbPath(env = process.env) {
return path.join(homeDir(env), '.claude', 'ecc2.db');
}
function defaultStateDbPath(env = process.env) {
return path.join(homeDir(env), DEFAULT_STATE_STORE_RELATIVE_PATH);
}
function defaultConfigPaths(cwd = process.cwd(), env = process.env) {
const home = homeDir(env);
const paths = [
@@ -75,11 +80,22 @@ function normalizeMemoryConnectors(connectors = {}) {
}
function normalizeConfig(rawConfig = {}, options = {}) {
const { memory_connectors: snakeMemoryConnectors, memoryConnectors, ...rest } = rawConfig;
const {
memory_connectors: snakeMemoryConnectors,
memoryConnectors,
state_db_path: snakeStateDbPath,
stateDbPath: camelStateDbPath,
...rest
} = rawConfig;
const normalized = normalizeObjectKeys(rest);
const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized.memoryConnectors;
return {
dbPath: options.dbPath || normalized.dbPath || defaultDbPath(options.env),
stateDbPath: options.stateDbPath
|| camelStateDbPath
|| snakeStateDbPath
|| normalized.stateDbPath
|| defaultStateDbPath(options.env),
memoryConnectors: normalizeMemoryConnectors(connectorConfig),
};
}
@@ -107,6 +123,7 @@ function resolveControlPaneConfig(options = {}) {
...normalizeConfig(merged, {
env,
dbPath: options.dbPath || env.ECC2_DB_PATH || null,
stateDbPath: options.stateDbPath || env.ECC_STATE_DB_PATH || null,
}),
configPaths: configPaths.filter(configPath => fs.existsSync(configPath)),
};
@@ -437,26 +454,120 @@ function connectorStatus(config, db) {
});
}
function normalizeWorkItemStatus(status) {
const normalized = String(status || 'open').trim().toLowerCase();
if (['done', 'closed', 'resolved', 'merged', 'cancelled'].includes(normalized)) return 'done';
if (['blocked', 'needs-review', 'failed', 'stalled'].includes(normalized)) return 'blocked';
if (['running', 'in-progress', 'active', 'working'].includes(normalized)) return 'running';
return 'ready';
}
function normalizeWorkItem(row) {
const parsedMetadata = parseJson(row.metadata, {});
const metadata = isPlainObject(parsedMetadata) ? normalizeObjectKeys(parsedMetadata) : {};
const kanbanState = normalizeWorkItemStatus(row.status);
return {
id: String(row.id || ''),
source: String(row.source || ''),
sourceId: row.source_id ? String(row.source_id) : null,
title: String(row.title || ''),
status: String(row.status || 'open'),
kanbanState,
priority: row.priority ? String(row.priority) : null,
url: row.url ? String(row.url) : null,
owner: row.owner ? String(row.owner) : null,
repoRoot: row.repo_root ? String(row.repo_root) : null,
sessionId: row.session_id ? String(row.session_id) : null,
branch: metadata.branch || metadata.headRefName || null,
mergeGate: metadata.mergeGate || metadata.mergeGateStatus || metadata.mergeStateStatus || null,
blocker: metadata.blocker || null,
acceptance: Array.isArray(metadata.acceptance) ? metadata.acceptance.map(String) : [],
metadata,
createdAt: String(row.created_at || ''),
updatedAt: String(row.updated_at || ''),
};
}
function readWorkItems(db) {
if (!tableExists(db, 'work_items')) return [];
return execRows(
db,
`SELECT *
FROM work_items
ORDER BY updated_at DESC, id DESC
LIMIT 100`
).map(normalizeWorkItem);
}
function summarizeWorkItems(items) {
const summary = {
totalCount: items.length,
openCount: 0,
blockedCount: 0,
doneCount: 0,
kanban: {
ready: 0,
running: 0,
blocked: 0,
done: 0,
},
items,
};
for (const item of items) {
const kanbanState = normalizeWorkItemStatus(item.kanbanState || item.status);
summary.kanban[kanbanState] += 1;
if (kanbanState === 'done') {
summary.doneCount += 1;
} else {
summary.openCount += 1;
}
if (kanbanState === 'blocked') summary.blockedCount += 1;
}
return summary;
}
async function readWorkItemsSnapshot(stateDbPath) {
let db = null;
try {
db = await openSqlDatabase(stateDbPath);
if (!db) return summarizeWorkItems([]);
return summarizeWorkItems(readWorkItems(db));
} catch {
return summarizeWorkItems([]);
} finally {
if (db) db.close();
}
}
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,
stateDbPath: options.stateDbPath || options.config.stateDbPath || null,
})
: resolveControlPaneConfig(options);
const dbPath = options.dbPath || config.dbPath;
const stateDbPath = options.stateDbPath || config.stateDbPath;
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 workItems = await readWorkItemsSnapshot(stateDbPath);
const base = {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
generatedAt,
repoRoot,
dbPath,
stateDbPath,
database: {
exists: Boolean(dbPath && fs.existsSync(dbPath)),
},
stateDatabase: {
exists: Boolean(stateDbPath && fs.existsSync(stateDbPath)),
},
config: {
configPaths: config.configPaths || [],
memoryConnectorCount: Object.keys(config.memoryConnectors || {}).length,
@@ -473,6 +584,7 @@ async function buildControlPaneSnapshot(options = {}) {
results: [],
},
connectors: connectorStatus(config, null),
workItems,
actions: buildControlPaneActions({ repoRoot, query, limit }),
};
@@ -513,6 +625,7 @@ module.exports = {
SNAPSHOT_SCHEMA_VERSION,
buildControlPaneSnapshot,
defaultConfigPaths,
defaultStateDbPath,
recallKnowledgeEntries,
resolveControlPaneConfig,
};

View File

@@ -215,6 +215,7 @@ function renderControlPaneHtml() {
.result,
.connector,
.work-item,
.action {
padding: 12px 14px;
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
@@ -224,10 +225,42 @@ function renderControlPaneHtml() {
.result:last-child,
.connector:last-child,
.work-item:last-child,
.action:last-child {
border-bottom: 0;
}
.kanban {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
}
.kanban-lane {
min-width: 0;
padding: 9px;
border: 1px solid rgba(52, 64, 56, 0.8);
border-radius: 6px;
background: #141917;
}
.kanban-lane span {
display: block;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0;
}
.kanban-lane strong {
display: block;
margin-top: 6px;
font-size: 20px;
line-height: 1;
}
.row {
display: flex;
justify-content: space-between;
@@ -310,6 +343,7 @@ function renderControlPaneHtml() {
@media (max-width: 560px) {
main { padding: 12px; }
.metrics { grid-template-columns: 1fr; }
.kanban { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.query { flex-direction: column; }
th:nth-child(4), td:nth-child(4) { display: none; }
}
@@ -338,6 +372,13 @@ function renderControlPaneHtml() {
</div>
<div id="sessions"></div>
</section>
<section>
<div class="section-head">
<h2>Work Items</h2>
<span class="subtle" id="work-item-count"></span>
</div>
<div id="work-items"></div>
</section>
</div>
<div class="stack">
<section>
@@ -413,7 +454,13 @@ function renderControlPaneHtml() {
function statePill(stateName) {
const state = String(stateName || 'unknown');
const klass = state === 'running' ? 'good' : state === 'failed' ? 'bad' : state === 'pending' ? 'warn' : 'blue';
const klass = ['running', 'done'].includes(state)
? 'good'
: ['failed', 'blocked'].includes(state)
? 'bad'
: ['pending', 'ready'].includes(state)
? 'warn'
: 'blue';
return '<span class="pill ' + klass + '">' + escapeHtml(state) + '</span>';
}
@@ -446,6 +493,37 @@ function renderControlPaneHtml() {
'</tbody></table>';
}
function renderWorkItems(workItems) {
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
const items = Array.isArray(summary.items) ? summary.items : [];
const kanban = summary.kanban || {};
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked';
const lanes = ['ready', 'running', 'blocked', 'done'];
const laneHtml = '<div class="kanban">' + lanes.map(lane =>
'<div class="kanban-lane"><span>' + escapeHtml(lane) + '</span><strong>' + escapeHtml(kanban[lane] || 0) + '</strong></div>'
).join('') + '</div>';
if (!items.length) {
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
return;
}
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
const owner = item.owner || item.source || 'unassigned';
return '<div class="work-item">' +
'<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' +
'<div class="subtle">' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' +
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
'</div>';
}).join('');
}
function renderKnowledge(knowledge) {
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
if (!knowledge.results.length) {
@@ -526,6 +604,7 @@ function renderControlPaneHtml() {
$('#action-status').textContent = snapshot.execution.allowActions ? 'local allowlist' : 'read-only';
renderMetrics(snapshot.summary);
renderSessions(snapshot.sessions);
renderWorkItems(snapshot.workItems);
renderKnowledge(snapshot.knowledge);
renderConnectors(snapshot.connectors);
renderActions(snapshot.actions.map(action => ({