mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-24 06:09:31 +08:00
feat(control-pane): add agent+human JIT assignment view to the work-items board
The kanban board tracked lanes (ready/running/blocked/done) but not WHO owns
each card, which is the missing piece for agent+human just-in-time team workflows.
- state.js: classifyAssignee() labels each work item agent | human | unassigned
(session-linked or agent-pattern owners = agent; named owners = human; ownerless
= unassigned), with an explicit metadata.assigneeKind override.
- summarizeWorkItems(): adds an assignment summary {agent,human,unassigned} over
OPEN cards plus a priority-sorted needsAssignment queue — the JIT pickup list.
- ui.js: cards show an [agent]/[human]/[unassigned] badge; the board header shows
agent/human split and 'N need owner'.
- Tests: assignment classification + JIT queue coverage in control-pane-state.
Full suite 2839/2839; lint green.
This commit is contained in:
parent
b3268fef80
commit
1efc399ab4
@ -26,11 +26,7 @@ function defaultStateDbPath(env = process.env) {
|
|||||||
|
|
||||||
function defaultConfigPaths(cwd = process.cwd(), env = process.env) {
|
function defaultConfigPaths(cwd = process.cwd(), env = process.env) {
|
||||||
const home = homeDir(env);
|
const home = homeDir(env);
|
||||||
const paths = [
|
const paths = [path.join(home, 'Library', 'Application Support', 'ecc2', 'config.toml'), path.join(home, '.config', 'ecc2', 'config.toml'), path.join(home, '.claude', 'ecc2.toml')];
|
||||||
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);
|
let current = path.resolve(cwd);
|
||||||
while (current && current !== path.dirname(current)) {
|
while (current && current !== path.dirname(current)) {
|
||||||
@ -66,9 +62,7 @@ function normalizeObjectKeys(value) {
|
|||||||
if (Array.isArray(value)) return value.map(normalizeObjectKeys);
|
if (Array.isArray(value)) return value.map(normalizeObjectKeys);
|
||||||
if (!isPlainObject(value)) return value;
|
if (!isPlainObject(value)) return value;
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [toCamelCase(key), normalizeObjectKeys(item)]));
|
||||||
Object.entries(value).map(([key, item]) => [toCamelCase(key), normalizeObjectKeys(item)])
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMemoryConnectors(connectors = {}) {
|
function normalizeMemoryConnectors(connectors = {}) {
|
||||||
@ -80,23 +74,13 @@ function normalizeMemoryConnectors(connectors = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfig(rawConfig = {}, options = {}) {
|
function normalizeConfig(rawConfig = {}, options = {}) {
|
||||||
const {
|
const { memory_connectors: snakeMemoryConnectors, memoryConnectors, state_db_path: snakeStateDbPath, stateDbPath: camelStateDbPath, ...rest } = rawConfig;
|
||||||
memory_connectors: snakeMemoryConnectors,
|
|
||||||
memoryConnectors,
|
|
||||||
state_db_path: snakeStateDbPath,
|
|
||||||
stateDbPath: camelStateDbPath,
|
|
||||||
...rest
|
|
||||||
} = rawConfig;
|
|
||||||
const normalized = normalizeObjectKeys(rest);
|
const normalized = normalizeObjectKeys(rest);
|
||||||
const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized.memoryConnectors;
|
const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized.memoryConnectors;
|
||||||
return {
|
return {
|
||||||
dbPath: options.dbPath || normalized.dbPath || defaultDbPath(options.env),
|
dbPath: options.dbPath || normalized.dbPath || defaultDbPath(options.env),
|
||||||
stateDbPath: options.stateDbPath
|
stateDbPath: options.stateDbPath || camelStateDbPath || snakeStateDbPath || normalized.stateDbPath || defaultStateDbPath(options.env),
|
||||||
|| camelStateDbPath
|
memoryConnectors: normalizeMemoryConnectors(connectorConfig)
|
||||||
|| snakeStateDbPath
|
|
||||||
|| normalized.stateDbPath
|
|
||||||
|| defaultStateDbPath(options.env),
|
|
||||||
memoryConnectors: normalizeMemoryConnectors(connectorConfig),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,9 +92,7 @@ function readTomlConfig(configPath) {
|
|||||||
function resolveControlPaneConfig(options = {}) {
|
function resolveControlPaneConfig(options = {}) {
|
||||||
const env = options.env || process.env;
|
const env = options.env || process.env;
|
||||||
const cwd = options.cwd || process.cwd();
|
const cwd = options.cwd || process.cwd();
|
||||||
const configPaths = options.configPath
|
const configPaths = options.configPath ? [path.resolve(options.configPath)] : defaultConfigPaths(cwd, env);
|
||||||
? [path.resolve(options.configPath)]
|
|
||||||
: defaultConfigPaths(cwd, env);
|
|
||||||
let merged = {};
|
let merged = {};
|
||||||
|
|
||||||
for (const configPath of configPaths) {
|
for (const configPath of configPaths) {
|
||||||
@ -123,9 +105,9 @@ function resolveControlPaneConfig(options = {}) {
|
|||||||
...normalizeConfig(merged, {
|
...normalizeConfig(merged, {
|
||||||
env,
|
env,
|
||||||
dbPath: options.dbPath || env.ECC2_DB_PATH || null,
|
dbPath: options.dbPath || env.ECC2_DB_PATH || null,
|
||||||
stateDbPath: options.stateDbPath || env.ECC_STATE_DB_PATH || null,
|
stateDbPath: options.stateDbPath || env.ECC_STATE_DB_PATH || null
|
||||||
}),
|
}),
|
||||||
configPaths: configPaths.filter(configPath => fs.existsSync(configPath)),
|
configPaths: configPaths.filter(configPath => fs.existsSync(configPath))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,11 +131,7 @@ function execRows(db, sql, params = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tableExists(db, tableName) {
|
function tableExists(db, tableName) {
|
||||||
const rows = execRows(
|
const rows = execRows(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", [tableName]);
|
||||||
db,
|
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
|
||||||
[tableName]
|
|
||||||
);
|
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +166,7 @@ function normalizeSession(row, unreadMessages) {
|
|||||||
? {
|
? {
|
||||||
path: String(row.worktree_path),
|
path: String(row.worktree_path),
|
||||||
branch: row.worktree_branch ? String(row.worktree_branch) : null,
|
branch: row.worktree_branch ? String(row.worktree_branch) : null,
|
||||||
base: row.worktree_base ? String(row.worktree_base) : null,
|
base: row.worktree_base ? String(row.worktree_base) : null
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
metrics: {
|
metrics: {
|
||||||
@ -198,23 +176,18 @@ function normalizeSession(row, unreadMessages) {
|
|||||||
toolCalls: toNumber(row.tool_calls),
|
toolCalls: toNumber(row.tool_calls),
|
||||||
filesChanged: toNumber(row.files_changed),
|
filesChanged: toNumber(row.files_changed),
|
||||||
durationSecs: toNumber(row.duration_secs),
|
durationSecs: toNumber(row.duration_secs),
|
||||||
costUsd: toNumber(row.cost_usd),
|
costUsd: toNumber(row.cost_usd)
|
||||||
},
|
},
|
||||||
unreadMessages: unreadMessages.get(id) || 0,
|
unreadMessages: unreadMessages.get(id) || 0,
|
||||||
createdAt: String(row.created_at || ''),
|
createdAt: String(row.created_at || ''),
|
||||||
updatedAt: String(row.updated_at || ''),
|
updatedAt: String(row.updated_at || ''),
|
||||||
lastHeartbeatAt: String(row.last_heartbeat_at || ''),
|
lastHeartbeatAt: String(row.last_heartbeat_at || '')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readUnreadMessageCounts(db) {
|
function readUnreadMessageCounts(db) {
|
||||||
if (!tableExists(db, 'messages')) return new Map();
|
if (!tableExists(db, 'messages')) return new Map();
|
||||||
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)]));
|
||||||
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) {
|
function readSessions(db) {
|
||||||
@ -241,7 +214,7 @@ function summarizeSessions(sessions) {
|
|||||||
unreadMessages: 0,
|
unreadMessages: 0,
|
||||||
activeWorktrees: 0,
|
activeWorktrees: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
totalCostUsd: 0,
|
totalCostUsd: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@ -278,7 +251,7 @@ function readEntities(db) {
|
|||||||
summary: String(row.summary || ''),
|
summary: String(row.summary || ''),
|
||||||
metadata: parseJson(row.metadata_json, {}),
|
metadata: parseJson(row.metadata_json, {}),
|
||||||
createdAt: String(row.created_at || ''),
|
createdAt: String(row.created_at || ''),
|
||||||
updatedAt: String(row.updated_at || ''),
|
updatedAt: String(row.updated_at || '')
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,7 +272,7 @@ function readObservations(db) {
|
|||||||
pinned: toNumber(row.pinned) === 1,
|
pinned: toNumber(row.pinned) === 1,
|
||||||
summary: String(row.summary || ''),
|
summary: String(row.summary || ''),
|
||||||
details: parseJson(row.details_json, {}),
|
details: parseJson(row.details_json, {}),
|
||||||
createdAt: String(row.created_at || ''),
|
createdAt: String(row.created_at || '')
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,7 +314,7 @@ function scoreEntity(entity, observations, relationCount, queryTerms) {
|
|||||||
{ text: entity.path || '', weight: 6 },
|
{ text: entity.path || '', weight: 6 },
|
||||||
{ text: entity.summary, weight: 8 },
|
{ text: entity.summary, weight: 8 },
|
||||||
{ text: metadataText, weight: 5 },
|
{ text: metadataText, weight: 5 },
|
||||||
{ text: observationText, weight: 10 },
|
{ text: observationText, weight: 10 }
|
||||||
].map(item => ({ ...item, text: item.text.toLowerCase() }));
|
].map(item => ({ ...item, text: item.text.toLowerCase() }));
|
||||||
const matchedTerms = [];
|
const matchedTerms = [];
|
||||||
let score = 0;
|
let score = 0;
|
||||||
@ -357,10 +330,7 @@ function scoreEntity(entity, observations, relationCount, queryTerms) {
|
|||||||
if (matched) matchedTerms.push(term);
|
if (matched) matchedTerms.push(term);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxPriority = observations.reduce(
|
const maxPriority = observations.reduce((highest, observation) => Math.max(highest, observation.priority), 0);
|
||||||
(highest, observation) => Math.max(highest, observation.priority),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const hasPinnedObservation = observations.some(observation => observation.pinned);
|
const hasPinnedObservation = observations.some(observation => observation.pinned);
|
||||||
score += Math.min(relationCount, 8);
|
score += Math.min(relationCount, 8);
|
||||||
score += maxPriority * 3;
|
score += maxPriority * 3;
|
||||||
@ -372,7 +342,7 @@ function scoreEntity(entity, observations, relationCount, queryTerms) {
|
|||||||
observationCount: observations.length,
|
observationCount: observations.length,
|
||||||
relationCount,
|
relationCount,
|
||||||
maxObservationPriority: maxPriority,
|
maxObservationPriority: maxPriority,
|
||||||
hasPinnedObservation,
|
hasPinnedObservation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,23 +358,21 @@ function recallKnowledgeEntries({ entities, observations, relationCounts, query,
|
|||||||
return entities
|
return entities
|
||||||
.map(entity => {
|
.map(entity => {
|
||||||
const entityObservations = observationsByEntity.get(entity.id) || [];
|
const entityObservations = observationsByEntity.get(entity.id) || [];
|
||||||
const score = queryTerms.length > 0
|
const score =
|
||||||
|
queryTerms.length > 0
|
||||||
? scoreEntity(entity, entityObservations, relationCounts.get(entity.id) || 0, queryTerms)
|
? scoreEntity(entity, entityObservations, relationCounts.get(entity.id) || 0, queryTerms)
|
||||||
: {
|
: {
|
||||||
score: entityObservations.some(observation => observation.pinned) ? 10 : 1,
|
score: entityObservations.some(observation => observation.pinned) ? 10 : 1,
|
||||||
matchedTerms: [],
|
matchedTerms: [],
|
||||||
observationCount: entityObservations.length,
|
observationCount: entityObservations.length,
|
||||||
relationCount: relationCounts.get(entity.id) || 0,
|
relationCount: relationCounts.get(entity.id) || 0,
|
||||||
maxObservationPriority: entityObservations.reduce(
|
maxObservationPriority: entityObservations.reduce((highest, observation) => Math.max(highest, observation.priority), 0),
|
||||||
(highest, observation) => Math.max(highest, observation.priority),
|
hasPinnedObservation: entityObservations.some(observation => observation.pinned)
|
||||||
0
|
|
||||||
),
|
|
||||||
hasPinnedObservation: entityObservations.some(observation => observation.pinned),
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
entity,
|
entity,
|
||||||
...score,
|
...score,
|
||||||
latestObservation: entityObservations[0] || null,
|
latestObservation: entityObservations[0] || null
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(entry => queryTerms.length === 0 || entry.matchedTerms.length > 0)
|
.filter(entry => queryTerms.length === 0 || entry.matchedTerms.length > 0)
|
||||||
@ -431,8 +399,8 @@ function connectorStatus(config, db) {
|
|||||||
String(row.connector_name),
|
String(row.connector_name),
|
||||||
{
|
{
|
||||||
syncedSources: toNumber(row.synced_sources),
|
syncedSources: toNumber(row.synced_sources),
|
||||||
lastSyncedAt: row.last_synced_at ? String(row.last_synced_at) : null,
|
lastSyncedAt: row.last_synced_at ? String(row.last_synced_at) : null
|
||||||
},
|
}
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -449,23 +417,56 @@ function connectorStatus(config, db) {
|
|||||||
defaultObservationType: connector.defaultObservationType || null,
|
defaultObservationType: connector.defaultObservationType || null,
|
||||||
includeSafeValues: Boolean(connector.includeSafeValues),
|
includeSafeValues: Boolean(connector.includeSafeValues),
|
||||||
syncedSources: checkpoint.syncedSources,
|
syncedSources: checkpoint.syncedSources,
|
||||||
lastSyncedAt: checkpoint.lastSyncedAt,
|
lastSyncedAt: checkpoint.lastSyncedAt
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWorkItemStatus(status) {
|
function normalizeWorkItemStatus(status) {
|
||||||
const normalized = String(status || 'open').trim().toLowerCase();
|
const normalized = String(status || 'open')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (['done', 'closed', 'resolved', 'merged', 'cancelled'].includes(normalized)) return 'done';
|
if (['done', 'closed', 'resolved', 'merged', 'cancelled'].includes(normalized)) return 'done';
|
||||||
if (['blocked', 'needs-review', 'failed', 'stalled'].includes(normalized)) return 'blocked';
|
if (['blocked', 'needs-review', 'failed', 'stalled'].includes(normalized)) return 'blocked';
|
||||||
if (['running', 'in-progress', 'active', 'working'].includes(normalized)) return 'running';
|
if (['running', 'in-progress', 'active', 'working'].includes(normalized)) return 'running';
|
||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Heuristics for whether a work item's owner is an autonomous agent or a human.
|
||||||
|
// Agent signals win when present so the board reflects who is *actively* on a card.
|
||||||
|
const AGENT_OWNER_RE = /(agent|claude|codex|hermes|gemini|opencode|qwen|joycode|codebuddy|\bbot\b|gpt|sonnet|opus|haiku|fable)/i;
|
||||||
|
const SESSION_ID_RE = /^(sid-|tx-|proj-|sess|session|run-|wt-)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify the assignment of a work item for agent+human JIT team workflows.
|
||||||
|
* Returns the assignee kind ('agent' | 'human' | 'unassigned') and the resolved
|
||||||
|
* assignee label, so the board can show who owns each card and which cards are
|
||||||
|
* waiting for a just-in-time pickup.
|
||||||
|
*/
|
||||||
|
function classifyAssignee({ owner, sessionId, metadata = {} }) {
|
||||||
|
const explicitKind = String(metadata.assigneeKind || metadata.ownerKind || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (explicitKind === 'agent' || explicitKind === 'human') {
|
||||||
|
return { assigneeKind: explicitKind, assignee: owner || sessionId || metadata.assignee || null };
|
||||||
|
}
|
||||||
|
const ownerStr = owner ? String(owner) : '';
|
||||||
|
if (sessionId || (ownerStr && (AGENT_OWNER_RE.test(ownerStr) || SESSION_ID_RE.test(ownerStr)))) {
|
||||||
|
return { assigneeKind: 'agent', assignee: ownerStr || String(sessionId) };
|
||||||
|
}
|
||||||
|
if (ownerStr) {
|
||||||
|
return { assigneeKind: 'human', assignee: ownerStr };
|
||||||
|
}
|
||||||
|
return { assigneeKind: 'unassigned', assignee: null };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWorkItem(row) {
|
function normalizeWorkItem(row) {
|
||||||
const parsedMetadata = parseJson(row.metadata, {});
|
const parsedMetadata = parseJson(row.metadata, {});
|
||||||
const metadata = isPlainObject(parsedMetadata) ? normalizeObjectKeys(parsedMetadata) : {};
|
const metadata = isPlainObject(parsedMetadata) ? normalizeObjectKeys(parsedMetadata) : {};
|
||||||
const kanbanState = normalizeWorkItemStatus(row.status);
|
const kanbanState = normalizeWorkItemStatus(row.status);
|
||||||
|
const owner = row.owner ? String(row.owner) : null;
|
||||||
|
const sessionId = row.session_id ? String(row.session_id) : null;
|
||||||
|
const { assigneeKind, assignee } = classifyAssignee({ owner, sessionId, metadata });
|
||||||
return {
|
return {
|
||||||
id: String(row.id || ''),
|
id: String(row.id || ''),
|
||||||
source: String(row.source || ''),
|
source: String(row.source || ''),
|
||||||
@ -475,16 +476,18 @@ function normalizeWorkItem(row) {
|
|||||||
kanbanState,
|
kanbanState,
|
||||||
priority: row.priority ? String(row.priority) : null,
|
priority: row.priority ? String(row.priority) : null,
|
||||||
url: row.url ? String(row.url) : null,
|
url: row.url ? String(row.url) : null,
|
||||||
owner: row.owner ? String(row.owner) : null,
|
owner,
|
||||||
|
assigneeKind,
|
||||||
|
assignee,
|
||||||
repoRoot: row.repo_root ? String(row.repo_root) : null,
|
repoRoot: row.repo_root ? String(row.repo_root) : null,
|
||||||
sessionId: row.session_id ? String(row.session_id) : null,
|
sessionId,
|
||||||
branch: metadata.branch || metadata.headRefName || null,
|
branch: metadata.branch || metadata.headRefName || null,
|
||||||
mergeGate: metadata.mergeGate || metadata.mergeGateStatus || metadata.mergeStateStatus || null,
|
mergeGate: metadata.mergeGate || metadata.mergeGateStatus || metadata.mergeStateStatus || null,
|
||||||
blocker: metadata.blocker || null,
|
blocker: metadata.blocker || null,
|
||||||
acceptance: Array.isArray(metadata.acceptance) ? metadata.acceptance.map(String) : [],
|
acceptance: Array.isArray(metadata.acceptance) ? metadata.acceptance.map(String) : [],
|
||||||
metadata,
|
metadata,
|
||||||
createdAt: String(row.created_at || ''),
|
createdAt: String(row.created_at || ''),
|
||||||
updatedAt: String(row.updated_at || ''),
|
updatedAt: String(row.updated_at || '')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -509,21 +512,53 @@ function summarizeWorkItems(items) {
|
|||||||
ready: 0,
|
ready: 0,
|
||||||
running: 0,
|
running: 0,
|
||||||
blocked: 0,
|
blocked: 0,
|
||||||
done: 0,
|
done: 0
|
||||||
},
|
},
|
||||||
items,
|
// Agent + human JIT team-workflow view: who owns the open work, and which
|
||||||
|
// open cards are waiting for a just-in-time pickup.
|
||||||
|
assignment: {
|
||||||
|
agent: 0,
|
||||||
|
human: 0,
|
||||||
|
unassigned: 0
|
||||||
|
},
|
||||||
|
needsAssignment: [],
|
||||||
|
items
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const kanbanState = normalizeWorkItemStatus(item.kanbanState || item.status);
|
const kanbanState = normalizeWorkItemStatus(item.kanbanState || item.status);
|
||||||
summary.kanban[kanbanState] += 1;
|
summary.kanban[kanbanState] += 1;
|
||||||
|
const isOpen = kanbanState !== 'done';
|
||||||
if (kanbanState === 'done') {
|
if (kanbanState === 'done') {
|
||||||
summary.doneCount += 1;
|
summary.doneCount += 1;
|
||||||
} else {
|
} else {
|
||||||
summary.openCount += 1;
|
summary.openCount += 1;
|
||||||
}
|
}
|
||||||
if (kanbanState === 'blocked') summary.blockedCount += 1;
|
if (kanbanState === 'blocked') summary.blockedCount += 1;
|
||||||
|
|
||||||
|
// Assignment is only meaningful for open work; done cards don't need an owner.
|
||||||
|
if (isOpen) {
|
||||||
|
const kind = item.assigneeKind || classifyAssignee(item).assigneeKind;
|
||||||
|
summary.assignment[kind] = (summary.assignment[kind] || 0) + 1;
|
||||||
|
if (kind === 'unassigned') {
|
||||||
|
summary.needsAssignment.push({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
kanbanState,
|
||||||
|
priority: item.priority || null,
|
||||||
|
url: item.url || null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface the highest-priority unclaimed work first for JIT pickup.
|
||||||
|
const priorityRank = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 };
|
||||||
|
summary.needsAssignment.sort((a, b) => {
|
||||||
|
const ra = priorityRank[String(a.priority || '').toLowerCase()] ?? 2;
|
||||||
|
const rb = priorityRank[String(b.priority || '').toLowerCase()] ?? 2;
|
||||||
|
return ra - rb;
|
||||||
|
});
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
@ -547,7 +582,7 @@ async function buildControlPaneSnapshot(options = {}) {
|
|||||||
? normalizeConfig(options.config, {
|
? normalizeConfig(options.config, {
|
||||||
env: options.env || process.env,
|
env: options.env || process.env,
|
||||||
dbPath: options.dbPath || options.config.dbPath || null,
|
dbPath: options.dbPath || options.config.dbPath || null,
|
||||||
stateDbPath: options.stateDbPath || options.config.stateDbPath || null,
|
stateDbPath: options.stateDbPath || options.config.stateDbPath || null
|
||||||
})
|
})
|
||||||
: resolveControlPaneConfig(options);
|
: resolveControlPaneConfig(options);
|
||||||
const dbPath = options.dbPath || config.dbPath;
|
const dbPath = options.dbPath || config.dbPath;
|
||||||
@ -563,17 +598,17 @@ async function buildControlPaneSnapshot(options = {}) {
|
|||||||
dbPath,
|
dbPath,
|
||||||
stateDbPath,
|
stateDbPath,
|
||||||
database: {
|
database: {
|
||||||
exists: Boolean(dbPath && fs.existsSync(dbPath)),
|
exists: Boolean(dbPath && fs.existsSync(dbPath))
|
||||||
},
|
},
|
||||||
stateDatabase: {
|
stateDatabase: {
|
||||||
exists: Boolean(stateDbPath && fs.existsSync(stateDbPath)),
|
exists: Boolean(stateDbPath && fs.existsSync(stateDbPath))
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
configPaths: config.configPaths || [],
|
configPaths: config.configPaths || [],
|
||||||
memoryConnectorCount: Object.keys(config.memoryConnectors || {}).length,
|
memoryConnectorCount: Object.keys(config.memoryConnectors || {}).length
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
allowActions: options.allowActions !== false,
|
allowActions: options.allowActions !== false
|
||||||
},
|
},
|
||||||
summary: summarizeSessions([]),
|
summary: summarizeSessions([]),
|
||||||
sessions: [],
|
sessions: [],
|
||||||
@ -581,11 +616,11 @@ async function buildControlPaneSnapshot(options = {}) {
|
|||||||
query,
|
query,
|
||||||
entityCount: 0,
|
entityCount: 0,
|
||||||
observationCount: 0,
|
observationCount: 0,
|
||||||
results: [],
|
results: []
|
||||||
},
|
},
|
||||||
connectors: connectorStatus(config, null),
|
connectors: connectorStatus(config, null),
|
||||||
workItems,
|
workItems,
|
||||||
actions: buildControlPaneActions({ repoRoot, query, limit }),
|
actions: buildControlPaneActions({ repoRoot, query, limit })
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = await openSqlDatabase(dbPath);
|
const db = await openSqlDatabase(dbPath);
|
||||||
@ -611,10 +646,10 @@ async function buildControlPaneSnapshot(options = {}) {
|
|||||||
observations,
|
observations,
|
||||||
relationCounts,
|
relationCounts,
|
||||||
query,
|
query,
|
||||||
limit,
|
limit
|
||||||
}),
|
})
|
||||||
},
|
},
|
||||||
connectors: connectorStatus(config, db),
|
connectors: connectorStatus(config, db)
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
@ -627,5 +662,5 @@ module.exports = {
|
|||||||
defaultConfigPaths,
|
defaultConfigPaths,
|
||||||
defaultStateDbPath,
|
defaultStateDbPath,
|
||||||
recallKnowledgeEntries,
|
recallKnowledgeEntries,
|
||||||
resolveControlPaneConfig,
|
resolveControlPaneConfig
|
||||||
};
|
};
|
||||||
|
|||||||
@ -497,7 +497,11 @@ function renderControlPaneHtml() {
|
|||||||
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
||||||
const items = Array.isArray(summary.items) ? summary.items : [];
|
const items = Array.isArray(summary.items) ? summary.items : [];
|
||||||
const kanban = summary.kanban || {};
|
const kanban = summary.kanban || {};
|
||||||
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked';
|
const needsAssignment = Array.isArray(summary.needsAssignment) ? summary.needsAssignment : [];
|
||||||
|
const assignment = summary.assignment || { agent: 0, human: 0, unassigned: 0 };
|
||||||
|
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
||||||
|
+ ' / ' + (assignment.agent || 0) + ' agent / ' + (assignment.human || 0) + ' human'
|
||||||
|
+ (needsAssignment.length ? ' / ' + needsAssignment.length + ' need owner' : '');
|
||||||
|
|
||||||
const lanes = ['ready', 'running', 'blocked', 'done'];
|
const lanes = ['ready', 'running', 'blocked', 'done'];
|
||||||
const laneHtml = '<div class="kanban">' + lanes.map(lane =>
|
const laneHtml = '<div class="kanban">' + lanes.map(lane =>
|
||||||
@ -513,10 +517,11 @@ function renderControlPaneHtml() {
|
|||||||
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
||||||
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
||||||
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
||||||
const owner = item.owner || item.source || 'unassigned';
|
const assigneeKind = item.assigneeKind || 'unassigned';
|
||||||
|
const owner = item.assignee || item.owner || (assigneeKind === 'unassigned' ? 'unassigned (JIT)' : item.source) || 'unassigned';
|
||||||
return '<div class="work-item">' +
|
return '<div class="work-item">' +
|
||||||
'<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' +
|
'<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>' +
|
'<div class="subtle">[' + escapeHtml(assigneeKind) + '] ' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' +
|
||||||
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
|
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
|
||||||
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
|
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
|
||||||
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
|
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
|
||||||
@ -629,5 +634,5 @@ function renderControlPaneHtml() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
renderControlPaneHtml,
|
renderControlPaneHtml
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,11 +9,7 @@ const path = require('path');
|
|||||||
|
|
||||||
const initSqlJs = require('sql.js');
|
const initSqlJs = require('sql.js');
|
||||||
|
|
||||||
const {
|
const { buildControlPaneSnapshot, recallKnowledgeEntries, resolveControlPaneConfig } = require('../../scripts/lib/control-pane/state');
|
||||||
buildControlPaneSnapshot,
|
|
||||||
recallKnowledgeEntries,
|
|
||||||
resolveControlPaneConfig,
|
|
||||||
} = require('../../scripts/lib/control-pane/state');
|
|
||||||
|
|
||||||
async function test(name, fn) {
|
async function test(name, fn) {
|
||||||
try {
|
try {
|
||||||
@ -138,7 +134,7 @@ async function writeSampleEcc2Database(dbPath) {
|
|||||||
0.42,
|
0.42,
|
||||||
'2026-06-03T10:00:00Z',
|
'2026-06-03T10:00:00Z',
|
||||||
'2026-06-03T10:15:00Z',
|
'2026-06-03T10:15:00Z',
|
||||||
'2026-06-03T10:15:00Z',
|
'2026-06-03T10:15:00Z'
|
||||||
]);
|
]);
|
||||||
insertSession.run([
|
insertSession.run([
|
||||||
'worker-kb',
|
'worker-kb',
|
||||||
@ -163,14 +159,18 @@ async function writeSampleEcc2Database(dbPath) {
|
|||||||
0.07,
|
0.07,
|
||||||
'2026-06-03T10:05:00Z',
|
'2026-06-03T10:05:00Z',
|
||||||
'2026-06-03T10:14:00Z',
|
'2026-06-03T10:14:00Z',
|
||||||
'2026-06-03T10:14:00Z',
|
'2026-06-03T10:14:00Z'
|
||||||
]);
|
]);
|
||||||
insertSession.free();
|
insertSession.free();
|
||||||
|
|
||||||
db.run(
|
db.run('INSERT INTO messages (from_session, to_session, content, msg_type, read, timestamp) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||||
'INSERT INTO messages (from_session, to_session, content, msg_type, read, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
'worker-kb',
|
||||||
['worker-kb', 'lead-hermes', 'Need approval for connector sync', 'approval_request', 0, '2026-06-03T10:16:00Z']
|
'lead-hermes',
|
||||||
);
|
'Need approval for connector sync',
|
||||||
|
'approval_request',
|
||||||
|
0,
|
||||||
|
'2026-06-03T10:16:00Z'
|
||||||
|
]);
|
||||||
|
|
||||||
const insertEntity = db.prepare(`
|
const insertEntity = db.prepare(`
|
||||||
INSERT INTO context_graph_entities (
|
INSERT INTO context_graph_entities (
|
||||||
@ -186,7 +186,7 @@ async function writeSampleEcc2Database(dbPath) {
|
|||||||
'How Affaan routes Hermes Desktop, Zellij panes, Devin-style delegation, and ECC release control work.',
|
'How Affaan routes Hermes Desktop, Zellij panes, Devin-style delegation, and ECC release control work.',
|
||||||
JSON.stringify({ source: 'hermes_workspace', platform: 'desktop' }),
|
JSON.stringify({ source: 'hermes_workspace', platform: 'desktop' }),
|
||||||
'2026-06-03T10:10:00Z',
|
'2026-06-03T10:10:00Z',
|
||||||
'2026-06-03T10:10:00Z',
|
'2026-06-03T10:10:00Z'
|
||||||
]);
|
]);
|
||||||
insertEntity.run([
|
insertEntity.run([
|
||||||
null,
|
null,
|
||||||
@ -197,13 +197,11 @@ async function writeSampleEcc2Database(dbPath) {
|
|||||||
'Operator knowledge base pattern for cross-platform agent memory.',
|
'Operator knowledge base pattern for cross-platform agent memory.',
|
||||||
JSON.stringify({ source: 'workspace_notes' }),
|
JSON.stringify({ source: 'workspace_notes' }),
|
||||||
'2026-06-03T10:11:00Z',
|
'2026-06-03T10:11:00Z',
|
||||||
'2026-06-03T10:11:00Z',
|
'2026-06-03T10:11:00Z'
|
||||||
]);
|
]);
|
||||||
insertEntity.free();
|
insertEntity.free();
|
||||||
|
|
||||||
db.run(
|
db.run('INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
|
||||||
'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[
|
|
||||||
'lead-hermes',
|
'lead-hermes',
|
||||||
1,
|
1,
|
||||||
'operator_memory',
|
'operator_memory',
|
||||||
@ -211,17 +209,22 @@ async function writeSampleEcc2Database(dbPath) {
|
|||||||
1,
|
1,
|
||||||
'Hermes Desktop and ECC should share recall before dispatching work.',
|
'Hermes Desktop and ECC should share recall before dispatching work.',
|
||||||
JSON.stringify({ note: 'safe public summary only' }),
|
JSON.stringify({ note: 'safe public summary only' }),
|
||||||
'2026-06-03T10:12:00Z',
|
'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 (?, ?, ?, ?, ?, ?)', [
|
||||||
db.run(
|
'lead-hermes',
|
||||||
'INSERT INTO context_graph_relations (session_id, from_entity_id, to_entity_id, relation_type, summary, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
1,
|
||||||
['lead-hermes', 1, 2, 'depends_on', 'Runbook uses durable memory concepts.', '2026-06-03T10:13:00Z']
|
2,
|
||||||
);
|
'depends_on',
|
||||||
db.run(
|
'Runbook uses durable memory concepts.',
|
||||||
'INSERT INTO context_graph_connector_checkpoints (connector_name, source_path, source_signature, updated_at) VALUES (?, ?, ?, ?)',
|
'2026-06-03T10:13:00Z'
|
||||||
['hermes_workspace', '/notes/hermes.md', 'sig-1', '2026-06-03T10:12: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()));
|
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||||
db.close();
|
db.close();
|
||||||
@ -270,10 +273,10 @@ async function writeSampleWorkItemsDatabase(dbPath) {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
branch: 'product/dynamic-workflow-team-orchestration',
|
branch: 'product/dynamic-workflow-team-orchestration',
|
||||||
mergeGate: 'focused tests and catalog check pass',
|
mergeGate: 'focused tests and catalog check pass',
|
||||||
acceptance: ['skill exists', 'content pack exists'],
|
acceptance: ['skill exists', 'content pack exists']
|
||||||
}),
|
}),
|
||||||
'2026-06-04T09:00:00Z',
|
'2026-06-04T09:00:00Z',
|
||||||
'2026-06-04T09:05:00Z',
|
'2026-06-04T09:05:00Z'
|
||||||
]);
|
]);
|
||||||
insertWorkItem.run([
|
insertWorkItem.run([
|
||||||
'agent-card-002',
|
'agent-card-002',
|
||||||
@ -288,10 +291,10 @@ async function writeSampleWorkItemsDatabase(dbPath) {
|
|||||||
null,
|
null,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
branch: 'product/ecc2-knowledge-control-pane',
|
branch: 'product/ecc2-knowledge-control-pane',
|
||||||
mergeStateStatus: 'CLEAN',
|
mergeStateStatus: 'CLEAN'
|
||||||
}),
|
}),
|
||||||
'2026-06-03T13:00:00Z',
|
'2026-06-03T13:00:00Z',
|
||||||
'2026-06-03T13:55:00Z',
|
'2026-06-03T13:55:00Z'
|
||||||
]);
|
]);
|
||||||
insertWorkItem.run([
|
insertWorkItem.run([
|
||||||
'agent-card-003',
|
'agent-card-003',
|
||||||
@ -306,10 +309,25 @@ async function writeSampleWorkItemsDatabase(dbPath) {
|
|||||||
null,
|
null,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
blocker: 'needs publish approval',
|
blocker: 'needs publish approval',
|
||||||
mergeGate: 'approval packet accepted',
|
mergeGate: 'approval packet accepted'
|
||||||
}),
|
}),
|
||||||
'2026-06-04T09:10:00Z',
|
'2026-06-04T09:10:00Z',
|
||||||
'2026-06-04T09:12:00Z',
|
'2026-06-04T09:12:00Z'
|
||||||
|
]);
|
||||||
|
insertWorkItem.run([
|
||||||
|
'agent-card-004',
|
||||||
|
'github-issue',
|
||||||
|
'2290',
|
||||||
|
'Triage 400k context window bug',
|
||||||
|
'open',
|
||||||
|
'high',
|
||||||
|
'https://github.com/affaan-m/ECC/issues/2290',
|
||||||
|
null,
|
||||||
|
'/repo/ecc',
|
||||||
|
null,
|
||||||
|
JSON.stringify({}),
|
||||||
|
'2026-06-04T09:20:00Z',
|
||||||
|
'2026-06-04T09:22:00Z'
|
||||||
]);
|
]);
|
||||||
insertWorkItem.free();
|
insertWorkItem.free();
|
||||||
|
|
||||||
@ -335,7 +353,8 @@ async function runTests() {
|
|||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
if (await test('builds an operator snapshot from ECC2 SQLite and configured connectors', async () => {
|
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-state-'));
|
||||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||||
|
|
||||||
@ -350,15 +369,15 @@ async function runTests() {
|
|||||||
hermes_workspace: {
|
hermes_workspace: {
|
||||||
kind: 'markdown_directory',
|
kind: 'markdown_directory',
|
||||||
path: '/notes',
|
path: '/notes',
|
||||||
recurse: true,
|
recurse: true
|
||||||
},
|
},
|
||||||
safe_env: {
|
safe_env: {
|
||||||
kind: 'dotenv_file',
|
kind: 'dotenv_file',
|
||||||
path: '/notes/.env',
|
path: '/notes/.env',
|
||||||
includeSafeValues: false,
|
includeSafeValues: false
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
|
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
|
||||||
@ -378,9 +397,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('projects state-store work items into agent Kanban summary', async () => {
|
if (
|
||||||
|
await test('projects state-store work items into agent Kanban summary', async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-work-items-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-work-items-'));
|
||||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||||
const stateDbPath = path.join(tempDir, 'state.db');
|
const stateDbPath = path.join(tempDir, 'state.db');
|
||||||
@ -393,28 +416,38 @@ async function runTests() {
|
|||||||
dbPath,
|
dbPath,
|
||||||
stateDbPath,
|
stateDbPath,
|
||||||
repoRoot: path.join(__dirname, '..', '..'),
|
repoRoot: path.join(__dirname, '..', '..'),
|
||||||
query: 'workflow',
|
query: 'workflow'
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(snapshot.workItems.totalCount, 3);
|
const byId = id => snapshot.workItems.items.find(item => item.id === id);
|
||||||
assert.strictEqual(snapshot.workItems.openCount, 2);
|
assert.strictEqual(snapshot.workItems.totalCount, 4);
|
||||||
|
assert.strictEqual(snapshot.workItems.openCount, 3);
|
||||||
assert.strictEqual(snapshot.workItems.blockedCount, 1);
|
assert.strictEqual(snapshot.workItems.blockedCount, 1);
|
||||||
assert.strictEqual(snapshot.workItems.doneCount, 1);
|
assert.strictEqual(snapshot.workItems.doneCount, 1);
|
||||||
assert.strictEqual(snapshot.workItems.kanban.running, 1);
|
assert.strictEqual(snapshot.workItems.kanban.running, 1);
|
||||||
assert.strictEqual(snapshot.workItems.kanban.blocked, 1);
|
assert.strictEqual(snapshot.workItems.kanban.blocked, 1);
|
||||||
assert.strictEqual(snapshot.workItems.items[0].id, 'agent-card-003');
|
assert.strictEqual(snapshot.workItems.kanban.ready, 1);
|
||||||
assert.strictEqual(snapshot.workItems.items[0].mergeGate, 'approval packet accepted');
|
assert.strictEqual(byId('agent-card-003').mergeGate, 'approval packet accepted');
|
||||||
assert.strictEqual(snapshot.workItems.items[1].branch, 'product/dynamic-workflow-team-orchestration');
|
assert.strictEqual(byId('agent-card-001').branch, 'product/dynamic-workflow-team-orchestration');
|
||||||
assert.strictEqual(
|
assert.strictEqual(byId('agent-card-002').mergeGate, 'CLEAN');
|
||||||
snapshot.workItems.items.find(item => item.id === 'agent-card-002').mergeGate,
|
|
||||||
'CLEAN'
|
// Agent + human JIT assignment view.
|
||||||
);
|
assert.strictEqual(byId('agent-card-001').assigneeKind, 'agent', 'session-linked card is agent-owned');
|
||||||
|
assert.strictEqual(byId('agent-card-003').assigneeKind, 'human', 'operator-owned card is human-owned');
|
||||||
|
assert.strictEqual(byId('agent-card-004').assigneeKind, 'unassigned', 'ownerless open card is unassigned');
|
||||||
|
assert.deepStrictEqual(snapshot.workItems.assignment, { agent: 1, human: 1, unassigned: 1 });
|
||||||
|
assert.strictEqual(snapshot.workItems.needsAssignment.length, 1);
|
||||||
|
assert.strictEqual(snapshot.workItems.needsAssignment[0].id, 'agent-card-004');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('treats an unreadable optional state-store database as empty work items', async () => {
|
if (
|
||||||
|
await test('treats an unreadable optional state-store database as empty work items', async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-corrupt-work-items-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-corrupt-work-items-'));
|
||||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||||
const stateDbPath = path.join(tempDir, 'corrupt-state.db');
|
const stateDbPath = path.join(tempDir, 'corrupt-state.db');
|
||||||
@ -427,7 +460,7 @@ async function runTests() {
|
|||||||
dbPath,
|
dbPath,
|
||||||
stateDbPath,
|
stateDbPath,
|
||||||
repoRoot: path.join(__dirname, '..', '..'),
|
repoRoot: path.join(__dirname, '..', '..'),
|
||||||
query: 'workflow',
|
query: 'workflow'
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(snapshot.stateDatabase.exists, true);
|
assert.strictEqual(snapshot.stateDatabase.exists, true);
|
||||||
@ -436,9 +469,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('resolves config from explicit db path and TOML connector file', async () => {
|
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-config-'));
|
||||||
const dbPath = path.join(tempDir, 'state.db');
|
const dbPath = path.join(tempDir, 'state.db');
|
||||||
const configPath = path.join(tempDir, 'ecc2.toml');
|
const configPath = path.join(tempDir, 'ecc2.toml');
|
||||||
@ -453,14 +490,14 @@ async function runTests() {
|
|||||||
'kind = "markdown_directory"',
|
'kind = "markdown_directory"',
|
||||||
'path = "/tmp/hermes"',
|
'path = "/tmp/hermes"',
|
||||||
'recurse = true',
|
'recurse = true',
|
||||||
'default_entity_type = "operator_note"',
|
'default_entity_type = "operator_note"'
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
|
||||||
const config = resolveControlPaneConfig({
|
const config = resolveControlPaneConfig({
|
||||||
cwd: tempDir,
|
cwd: tempDir,
|
||||||
configPath,
|
configPath
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(config.dbPath, dbPath);
|
assert.strictEqual(config.dbPath, dbPath);
|
||||||
@ -469,9 +506,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('prefers the operator home config over stale app-support config', async () => {
|
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-precedence-'));
|
||||||
const homeDir = path.join(tempDir, 'home');
|
const homeDir = path.join(tempDir, 'home');
|
||||||
const homeConfigDir = path.join(homeDir, '.claude');
|
const homeConfigDir = path.join(homeDir, '.claude');
|
||||||
@ -482,29 +523,25 @@ async function runTests() {
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(homeConfigDir, { recursive: true });
|
fs.mkdirSync(homeConfigDir, { recursive: true });
|
||||||
fs.mkdirSync(appConfigDir, { recursive: true });
|
fs.mkdirSync(appConfigDir, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(path.join(appConfigDir, 'config.toml'), `db_path = "${staleDbPath.replace(/\\/g, '\\\\')}"\n`, 'utf8');
|
||||||
path.join(appConfigDir, 'config.toml'),
|
fs.writeFileSync(path.join(homeConfigDir, 'ecc2.toml'), `db_path = "${homeDbPath.replace(/\\/g, '\\\\')}"\n`, 'utf8');
|
||||||
`db_path = "${staleDbPath.replace(/\\/g, '\\\\')}"\n`,
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(homeConfigDir, 'ecc2.toml'),
|
|
||||||
`db_path = "${homeDbPath.replace(/\\/g, '\\\\')}"\n`,
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = resolveControlPaneConfig({
|
const config = resolveControlPaneConfig({
|
||||||
cwd: tempDir,
|
cwd: tempDir,
|
||||||
env: { HOME: homeDir },
|
env: { HOME: homeDir }
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(config.dbPath, homeDbPath);
|
assert.strictEqual(config.dbPath, homeDbPath);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('shows configured connectors even when the SQLite database is missing', async () => {
|
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-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-missing-db-'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -516,10 +553,10 @@ async function runTests() {
|
|||||||
hermes_workspace: {
|
hermes_workspace: {
|
||||||
kind: 'markdown_directory',
|
kind: 'markdown_directory',
|
||||||
path: '/notes/hermes',
|
path: '/notes/hermes',
|
||||||
recurse: true,
|
recurse: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(snapshot.database.exists, false);
|
assert.strictEqual(snapshot.database.exists, false);
|
||||||
@ -529,9 +566,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('handles an existing SQLite database before ECC2 tables are created', async () => {
|
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-empty-db-'));
|
||||||
const dbPath = path.join(tempDir, 'empty.db');
|
const dbPath = path.join(tempDir, 'empty.db');
|
||||||
|
|
||||||
@ -549,10 +590,10 @@ async function runTests() {
|
|||||||
workspace_notes: {
|
workspace_notes: {
|
||||||
kind: 'markdown_directory',
|
kind: 'markdown_directory',
|
||||||
path: '/notes',
|
path: '/notes',
|
||||||
includeSafeValues: false,
|
includeSafeValues: false
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(snapshot.database.exists, true);
|
assert.strictEqual(snapshot.database.exists, true);
|
||||||
@ -564,9 +605,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('recalls pinned knowledge when no query is provided', async () => {
|
if (
|
||||||
|
await test('recalls pinned knowledge when no query is provided', async () => {
|
||||||
const results = recallKnowledgeEntries({
|
const results = recallKnowledgeEntries({
|
||||||
entities: [
|
entities: [
|
||||||
{
|
{
|
||||||
@ -576,7 +621,7 @@ async function runTests() {
|
|||||||
path: '/notes/pinned.md',
|
path: '/notes/pinned.md',
|
||||||
summary: 'Pinned operator context',
|
summary: 'Pinned operator context',
|
||||||
metadata: {},
|
metadata: {},
|
||||||
updatedAt: '2026-06-03T10:00:00Z',
|
updatedAt: '2026-06-03T10:00:00Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@ -585,26 +630,26 @@ async function runTests() {
|
|||||||
path: null,
|
path: null,
|
||||||
summary: 'Secondary context',
|
summary: 'Secondary context',
|
||||||
metadata: {},
|
metadata: {},
|
||||||
updatedAt: '2026-06-03T11:00:00Z',
|
updatedAt: '2026-06-03T11:00:00Z'
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
entityId: 1,
|
entityId: 1,
|
||||||
priority: 4,
|
priority: 4,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
summary: 'Pinned detail',
|
summary: 'Pinned detail'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entityId: 2,
|
entityId: 2,
|
||||||
priority: 2,
|
priority: 2,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
summary: 'Other detail',
|
summary: 'Other detail'
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
relationCounts: new Map([[1, 3]]),
|
relationCounts: new Map([[1, 3]]),
|
||||||
query: '',
|
query: '',
|
||||||
limit: 0,
|
limit: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(results.length, 2);
|
assert.strictEqual(results.length, 2);
|
||||||
@ -612,9 +657,13 @@ async function runTests() {
|
|||||||
assert.strictEqual(results[0].hasPinnedObservation, true);
|
assert.strictEqual(results[0].hasPinnedObservation, true);
|
||||||
assert.strictEqual(results[0].relationCount, 3);
|
assert.strictEqual(results[0].relationCount, 3);
|
||||||
assert.strictEqual(results[1].entity.name, 'Unpinned concept');
|
assert.strictEqual(results[1].entity.name, 'Unpinned concept');
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('handles malformed JSON rows and all session state counters', async () => {
|
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-edge-db-'));
|
||||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||||
|
|
||||||
@ -653,7 +702,7 @@ async function runTests() {
|
|||||||
state === 'failed' ? 'not-cost' : 0.1,
|
state === 'failed' ? 'not-cost' : 0.1,
|
||||||
'2026-06-03T11:00:00Z',
|
'2026-06-03T11:00:00Z',
|
||||||
`2026-06-03T11:0${state.length % 10}:00Z`,
|
`2026-06-03T11:0${state.length % 10}:00Z`,
|
||||||
'',
|
''
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
insertSession.free();
|
insertSession.free();
|
||||||
@ -671,12 +720,10 @@ async function runTests() {
|
|||||||
'This record should still be searchable.',
|
'This record should still be searchable.',
|
||||||
'{bad json',
|
'{bad json',
|
||||||
'2026-06-03T11:20:00Z',
|
'2026-06-03T11:20:00Z',
|
||||||
'2026-06-03T11:20:00Z',
|
'2026-06-03T11:20:00Z'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
db.run(
|
db.run('INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
|
||||||
'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[
|
|
||||||
'session-failed',
|
'session-failed',
|
||||||
3,
|
3,
|
||||||
'',
|
'',
|
||||||
@ -684,9 +731,8 @@ async function runTests() {
|
|||||||
0,
|
0,
|
||||||
'Malformed details should fall back safely.',
|
'Malformed details should fall back safely.',
|
||||||
'{bad json',
|
'{bad json',
|
||||||
'2026-06-03T11:21:00Z',
|
'2026-06-03T11:21:00Z'
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await buildControlPaneSnapshot({
|
const snapshot = await buildControlPaneSnapshot({
|
||||||
@ -701,10 +747,10 @@ async function runTests() {
|
|||||||
recurse: false,
|
recurse: false,
|
||||||
defaultEntityType: 'note',
|
defaultEntityType: 'note',
|
||||||
defaultObservationType: 'operator_memory',
|
defaultObservationType: 'operator_memory',
|
||||||
includeSafeValues: true,
|
includeSafeValues: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(snapshot.summary.pendingSessions, 1);
|
assert.strictEqual(snapshot.summary.pendingSessions, 1);
|
||||||
@ -732,9 +778,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('recall search covers metadata, relation caps, no matches, and tie ordering', async () => {
|
if (
|
||||||
|
await test('recall search covers metadata, relation caps, no matches, and tie ordering', async () => {
|
||||||
const baseEntities = [
|
const baseEntities = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -743,7 +793,7 @@ async function runTests() {
|
|||||||
path: '/notes/shared-a.md',
|
path: '/notes/shared-a.md',
|
||||||
summary: 'Platform context',
|
summary: 'Platform context',
|
||||||
metadata: { source: 'workspace' },
|
metadata: { source: 'workspace' },
|
||||||
updatedAt: '2026-06-03T10:00:00Z',
|
updatedAt: '2026-06-03T10:00:00Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@ -752,7 +802,7 @@ async function runTests() {
|
|||||||
path: '/notes/shared-b.md',
|
path: '/notes/shared-b.md',
|
||||||
summary: 'Platform context',
|
summary: 'Platform context',
|
||||||
metadata: { source: 'workspace' },
|
metadata: { source: 'workspace' },
|
||||||
updatedAt: '2026-06-03T12:00:00Z',
|
updatedAt: '2026-06-03T12:00:00Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@ -761,16 +811,16 @@ async function runTests() {
|
|||||||
path: null,
|
path: null,
|
||||||
summary: 'Correlation graph visualization',
|
summary: 'Correlation graph visualization',
|
||||||
metadata: { flow: 'friction-flow' },
|
metadata: { flow: 'friction-flow' },
|
||||||
updatedAt: '2026-06-03T09:00:00Z',
|
updatedAt: '2026-06-03T09:00:00Z'
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
const observations = [
|
const observations = [
|
||||||
{
|
{
|
||||||
entityId: 3,
|
entityId: 3,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
summary: 'Ito should expose market backtesting through ECC tools.',
|
summary: 'Ito should expose market backtesting through ECC tools.'
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const tied = recallKnowledgeEntries({
|
const tied = recallKnowledgeEntries({
|
||||||
@ -778,16 +828,19 @@ async function runTests() {
|
|||||||
observations: [],
|
observations: [],
|
||||||
relationCounts: new Map(),
|
relationCounts: new Map(),
|
||||||
query: 'shared',
|
query: 'shared',
|
||||||
limit: 50,
|
limit: 50
|
||||||
});
|
});
|
||||||
assert.deepStrictEqual(tied.map(entry => entry.entity.id), [2, 1]);
|
assert.deepStrictEqual(
|
||||||
|
tied.map(entry => entry.entity.id),
|
||||||
|
[2, 1]
|
||||||
|
);
|
||||||
|
|
||||||
const metadataHit = recallKnowledgeEntries({
|
const metadataHit = recallKnowledgeEntries({
|
||||||
entities: baseEntities,
|
entities: baseEntities,
|
||||||
observations,
|
observations,
|
||||||
relationCounts: new Map([[3, 20]]),
|
relationCounts: new Map([[3, 20]]),
|
||||||
query: 'friction-flow backtesting',
|
query: 'friction-flow backtesting',
|
||||||
limit: -5,
|
limit: -5
|
||||||
});
|
});
|
||||||
assert.strictEqual(metadataHit.length, 1);
|
assert.strictEqual(metadataHit.length, 1);
|
||||||
assert.strictEqual(metadataHit[0].entity.id, 3);
|
assert.strictEqual(metadataHit[0].entity.id, 3);
|
||||||
@ -799,10 +852,13 @@ async function runTests() {
|
|||||||
observations,
|
observations,
|
||||||
relationCounts: new Map(),
|
relationCounts: new Map(),
|
||||||
query: 'unmatched',
|
query: 'unmatched',
|
||||||
limit: 'wat',
|
limit: 'wat'
|
||||||
});
|
});
|
||||||
assert.deepStrictEqual(noHits, []);
|
assert.deepStrictEqual(noHits, []);
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user