From 6c5c5c843a28a1af8c7ca76e472ed0d49859b483 Mon Sep 17 00:00:00 2001 From: Joshua Muthumani Date: Sun, 30 Aug 2026 20:35:05 -0700 Subject: [PATCH 1/6] fix(server): resolve Hermes home from HERMES_HOME env, not hardcoded ~/.hermes discoverGatewayPorts(), getDefaultModel(), and the /api/setup/check config check all hardcoded path.join(os.homedir(), '.hermes'), which breaks under Docker or any environment where $HOME does not point at the real Hermes state root. Route them through the already-defined HERMES_HOME constant (env override, falls back to the same default). Pre-existing bug surfaced while wiring up the Multica telemetry bridge; split out as its own fix since it is unrelated to that feature. --- server.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index cb53736..341ed43 100644 --- a/server.js +++ b/server.js @@ -347,7 +347,7 @@ function resolveCorsOrigins(req) { // Scans ~/.hermes/config.yaml (default) + ~/.hermes/profiles/*/config.yaml function discoverGatewayPorts() { const ports = {}; - const baseHermesHome = path.join(os.homedir(), '.hermes'); + const baseHermesHome = HERMES_HOME; // was hardcoded os.homedir()+'.hermes' — broke under Docker where $HOME != the real state root try { // Default profile: ~/.hermes/config.yaml (base, not HERMES_HOME which may be profile-specific) const defaultConf = fs.readFileSync(path.join(baseHermesHome, 'config.yaml'), 'utf8'); @@ -424,7 +424,7 @@ async function probeGatewayHealth(profile) { // Read default model from profile config.yaml function getDefaultModel(profile) { - const baseHermesHome = path.join(os.homedir(), '.hermes'); + const baseHermesHome = HERMES_HOME; // was hardcoded os.homedir()+'.hermes' — see discoverGatewayPorts() above try { const configPath = profile === 'default' ? path.join(baseHermesHome, 'config.yaml') @@ -5698,7 +5698,7 @@ app.get('/api/setup/check', requireAuth, async (req, res) => { }); // 4. Check hermes config.yaml - const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); + const configPath = path.join(HERMES_HOME, 'config.yaml'); // was os.homedir()+'.hermes' — same bug as discoverGatewayPorts() try { const raw = fs.readFileSync(configPath, 'utf8'); yaml.load(raw); From 89d61217cbe1a7dec72e71f179fa67a788a609b9 Mon Sep 17 00:00:00 2001 From: Joshua Muthumani Date: Sun, 30 Aug 2026 20:35:27 -0700 Subject: [PATCH 2/6] feat(multica): add CLI adapter, telemetry bridge, and snapshot builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the core of the Multica-to-Hermes execution-observability bridge (JOSH-42). Multica remains canonical for issue ownership, status, comments, and swarm structure; this is a read-only telemetry mirror. - lib/multica-cli.js: thin adapter, the only module that shells out to the `multica` CLI. Always requests `--output json`; never scrapes human-formatted table output. - lib/multica-bridge.js: pure normalization layer — run selection, queued/stale detection (heartbeat-age based), swarm aggregation, and the full execution-state matrix (unassigned -> assigned_idle -> queued -> running/stale -> waiting_review/blocked -> succeeded/failed/cancelled -> unknown). - lib/multica-telemetry.js: orchestrates the CLI adapter + bridge into a single snapshot. Per-issue telemetry failures are flagged (telemetryError, forced to the `unknown` state) rather than dropped or shown as healthy. --- lib/multica-bridge.js | 291 +++++++++++++++++++++++++++++++++++++++ lib/multica-cli.js | 110 +++++++++++++++ lib/multica-telemetry.js | 106 ++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 lib/multica-bridge.js create mode 100644 lib/multica-cli.js create mode 100644 lib/multica-telemetry.js diff --git a/lib/multica-bridge.js b/lib/multica-bridge.js new file mode 100644 index 0000000..0c5149d --- /dev/null +++ b/lib/multica-bridge.js @@ -0,0 +1,291 @@ +// lib/multica-bridge.js — pure normalization for Multica execution telemetry. +// +// No I/O here. Every function takes plain data (as already fetched via +// lib/multica-cli.js) and returns plain data. This is what keeps state +// mapping, stale detection, and swarm aggregation independently unit +// testable without spawning the CLI or touching the network — see +// test/multica-bridge.test.js. +// +// ── Execution states (the model required by JOSH-42) ──────────────────────── +// UNASSIGNED — no member/agent/squad owns the issue yet. +// ASSIGNED_IDLE — an agent/squad owns it, but no run is currently active. +// QUEUED — a run was dispatched but has not started executing yet. +// RUNNING — a run is actively executing and its heartbeat is fresh. +// STALE — a run claims to be running but its last activity is +// older than the stale threshold. Never reported as RUNNING. +// WAITING_REVIEW — issue status is in_review and no run is currently active. +// BLOCKED — issue status is blocked and no run is currently active. +// SUCCEEDED — issue status is done (Multica issue status is canonical). +// FAILED — issue is open, no active run, and the most recent run failed. +// CANCELLED — issue status is cancelled, or the most recent run was +// cancelled and the issue is still open. +// UNKNOWN — telemetry could not be read (CLI/API error). Must never +// be presented as healthy. +'use strict'; + +const EXECUTION_STATES = Object.freeze({ + UNASSIGNED: 'unassigned', + ASSIGNED_IDLE: 'assigned_idle', + QUEUED: 'queued', + RUNNING: 'running', + STALE: 'stale', + WAITING_REVIEW: 'waiting_review', + BLOCKED: 'blocked', + SUCCEEDED: 'succeeded', + FAILED: 'failed', + CANCELLED: 'cancelled', + UNKNOWN: 'unknown', +}); + +const DEFAULT_STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes with no heartbeat + +function toEpochMs(value) { + if (!value) return 0; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : 0; +} + +/** Normalize one raw `issue runs` record into a compact, display-ready shape. */ +function normalizeRun(raw) { + if (!raw || typeof raw !== 'object') return null; + return { + id: String(raw.id || ''), + status: String(raw.status || 'unknown'), + agentId: raw.agent_id || null, + runtimeId: raw.runtime_id || null, + createdAt: raw.created_at || null, + dispatchedAt: raw.dispatched_at || null, + startedAt: raw.started_at || null, + completedAt: raw.completed_at || null, + error: raw.error || null, + triggerSummary: raw.trigger_summary || null, + attempt: Number(raw.attempt || 1), + }; +} + +/** Runs are not guaranteed to be sorted; sort newest-created-first, defensively. */ +function sortRunsDesc(runs) { + return [...runs].sort((a, b) => toEpochMs(b.createdAt) - toEpochMs(a.createdAt)); +} + +/** The run currently executing, if any — a `status: 'running'` record. */ +function pickActiveRun(normalizedRuns) { + const running = normalizedRuns.filter((r) => r.status === 'running'); + if (!running.length) return null; + return sortRunsDesc(running)[0]; +} + +/** Most recent run that has reached a terminal state (completed/failed/cancelled). */ +function pickLastTerminalRun(normalizedRuns) { + const terminal = normalizedRuns.filter((r) => r.status !== 'running'); + if (!terminal.length) return null; + return sortRunsDesc(terminal)[0]; +} + +/** + * A run counts as merely QUEUED (dispatched, not yet executing) when it has + * no `startedAt` timestamp yet. Once `startedAt` is set, it is RUNNING + * (subject to the stale check below). + */ +function isQueued(activeRun) { + return !!activeRun && !activeRun.startedAt; +} + +/** + * A running task is STALE when nothing has moved — no issue activity — for + * longer than `staleThresholdMs`. `lastActivityAt` should be the owning + * issue's `last_activity_at` (comments, run messages, and status changes all + * bump it), used as the best available heartbeat proxy since Multica does + * not expose a lower-level per-run heartbeat over the CLI contract. + */ +function isStale(activeRun, lastActivityAt, nowMs, staleThresholdMs = DEFAULT_STALE_THRESHOLD_MS) { + if (!activeRun || isQueued(activeRun)) return false; + const lastBeat = Math.max(toEpochMs(lastActivityAt), toEpochMs(activeRun.startedAt)); + if (!lastBeat) return true; // no evidence of activity at all — treat as stale, not healthy + return (nowMs - lastBeat) > staleThresholdMs; +} + +/** Group an issue's children (already fetched via `issue children`) by run/status state. */ +function summarizeSwarm(childrenPayload) { + const stages = Array.isArray(childrenPayload?.stages) ? childrenPayload.stages : []; + const unstaged = Array.isArray(childrenPayload?.unstaged) ? childrenPayload.unstaged : []; + const allChildren = [ + ...stages.flatMap((s) => (Array.isArray(s.issues) ? s.issues : [])), + ...unstaged, + ]; + + const byStatus = {}; + for (const child of allChildren) { + const status = child.status || 'unknown'; + byStatus[status] = (byStatus[status] || 0) + 1; + } + + const activeStatuses = new Set(['in_progress', 'todo', 'in_review', 'blocked']); + const hasActiveChildren = allChildren.some((c) => activeStatuses.has(c.status)); + + return { + totalChildren: allChildren.length, + byStatus, + hasActiveChildren, + isSwarm: allChildren.length > 0, + }; +} + +/** Resolve a human-readable agent name from the agents list, falling back to the raw id. */ +function resolveAgentName(agentId, agentsById) { + if (!agentId) return null; + const agent = agentsById?.[agentId]; + return agent?.name || agentId; +} + +/** Resolve a human-readable runtime name from the runtimes list, falling back to the raw id. */ +function resolveRuntimeName(runtimeId, runtimesById) { + if (!runtimeId) return null; + const runtime = runtimesById?.[runtimeId]; + if (!runtime) return runtimeId; + return runtime.custom_name || runtime.name || runtimeId; +} + +/** Build the canonical deep link back to the issue in the Multica web app. */ +function buildIssueDeepLink({ appUrl, workspaceSlug, identifier }) { + if (!appUrl || !workspaceSlug || !identifier) return null; + const base = String(appUrl).replace(/\/+$/, ''); + return `${base}/${workspaceSlug}/issues/${identifier}`; +} + +/** + * Classify the single execution state for one issue given its runs and + * children summary. This is the state-mapping contract every caller + * (API route, UI, tests) should go through — never re-derive state ad hoc. + */ +function classifyExecutionState({ issue, activeRun, lastTerminalRun, stale }) { + const status = issue?.status; + + if (status === 'cancelled') return EXECUTION_STATES.CANCELLED; + if (status === 'done') return EXECUTION_STATES.SUCCEEDED; + + if (activeRun) { + if (stale) return EXECUTION_STATES.STALE; + if (isQueued(activeRun)) return EXECUTION_STATES.QUEUED; + return EXECUTION_STATES.RUNNING; + } + + if (status === 'in_review') return EXECUTION_STATES.WAITING_REVIEW; + if (status === 'blocked') return EXECUTION_STATES.BLOCKED; + + if (lastTerminalRun?.status === 'failed') return EXECUTION_STATES.FAILED; + if (lastTerminalRun?.status === 'cancelled') return EXECUTION_STATES.CANCELLED; + + if (issue?.assignee_type) return EXECUTION_STATES.ASSIGNED_IDLE; + + return EXECUTION_STATES.UNASSIGNED; +} + +/** + * Combine one issue's raw CLI payloads (issue, runs, children) into the + * display-ready execution record consumed by the API route and the UI. + * + * @param {object} params + * @param {object} params.issue raw `issue get`/`issue list` record + * @param {object[]} params.rawRuns raw `issue runs` array (may be empty) + * @param {object} params.childrenPayload raw `issue children` payload + * @param {object} params.agentsById { [agentId]: agentRecord } + * @param {object} params.runtimesById { [runtimeId]: runtimeRecord } + * @param {object} [params.linkConfig] { appUrl, workspaceSlug } + * @param {number} [params.nowMs] injectable clock for tests + * @param {number} [params.staleThresholdMs] + */ +function normalizeIssueExecution({ + issue, + rawRuns = [], + childrenPayload = null, + agentsById = {}, + runtimesById = {}, + linkConfig = {}, + nowMs = Date.now(), + staleThresholdMs = DEFAULT_STALE_THRESHOLD_MS, +}) { + const runs = (rawRuns || []).map(normalizeRun).filter(Boolean); + const activeRun = pickActiveRun(runs); + const lastTerminalRun = pickLastTerminalRun(runs); + const stale = isStale(activeRun, issue?.last_activity_at, nowMs, staleThresholdMs); + const executionState = classifyExecutionState({ issue, activeRun, lastTerminalRun, stale }); + const swarm = summarizeSwarm(childrenPayload); + + const displayRun = activeRun || lastTerminalRun; + + return { + issueId: issue?.id || null, + identifier: issue?.identifier || null, + title: issue?.title || null, + issueStatus: issue?.status || null, + assigneeType: issue?.assignee_type || null, + assigneeId: issue?.assignee_id || null, + executionState, + isStale: stale, + run: displayRun + ? { + id: displayRun.id, + status: displayRun.status, + agentId: displayRun.agentId, + agentName: resolveAgentName(displayRun.agentId, agentsById), + runtimeId: displayRun.runtimeId, + runtimeName: resolveRuntimeName(displayRun.runtimeId, runtimesById), + startedAt: displayRun.startedAt, + completedAt: displayRun.completedAt, + createdAt: displayRun.createdAt, + error: displayRun.error, + } + : null, + lastActivityAt: issue?.last_activity_at || null, + swarm, + deepLink: buildIssueDeepLink({ + appUrl: linkConfig.appUrl, + workspaceSlug: linkConfig.workspaceSlug, + identifier: issue?.identifier, + }), + parentIssueId: issue?.parent_issue_id || null, + }; +} + +/** Index an array of records (agents/runtimes) by `id` for O(1) lookups. */ +function indexById(records) { + const out = {}; + for (const r of records || []) { + if (r && r.id) out[r.id] = r; + } + return out; +} + +/** + * Decide whether an issue is worth including in the "active worker" view at + * all — anything not done/cancelled, or done/cancelled very recently, so the + * board doesn't get polluted with the full historical backlog. This is the + * "preserve history without polluting the active view" requirement. + */ +function isActiveOrRecentIssue(issue, nowMs = Date.now(), recentWindowMs = 15 * 60 * 1000) { + if (!issue) return false; + const terminal = issue.status === 'done' || issue.status === 'cancelled'; + if (!terminal) return true; + const lastActivityMs = toEpochMs(issue.last_activity_at || issue.updated_at); + return (nowMs - lastActivityMs) <= recentWindowMs; +} + +module.exports = { + EXECUTION_STATES, + DEFAULT_STALE_THRESHOLD_MS, + normalizeRun, + sortRunsDesc, + pickActiveRun, + pickLastTerminalRun, + isQueued, + isStale, + summarizeSwarm, + resolveAgentName, + resolveRuntimeName, + buildIssueDeepLink, + classifyExecutionState, + normalizeIssueExecution, + indexById, + isActiveOrRecentIssue, +}; diff --git a/lib/multica-cli.js b/lib/multica-cli.js new file mode 100644 index 0000000..68bf27f --- /dev/null +++ b/lib/multica-cli.js @@ -0,0 +1,110 @@ +// lib/multica-cli.js — thin adapter over the `multica` CLI's JSON contract. +// +// This is the ONLY place that shells out to `multica`. It never scrapes +// human-formatted table output — every call appends `--output json` and +// parses structured JSON, per the integration contract in +// docs/MULTICA_BRIDGE.md. +// +// Every function returns `{ ok: true, data }` or `{ ok: false, error }` — +// callers must handle both explicitly. No exception is ever thrown across +// this boundary; execution telemetry must fail visibly (surfaced as `ok:false` +// / a warning in the API response) rather than crash the dashboard or +// silently show a stale/empty view as healthy. +'use strict'; + +const { execFile } = require('child_process'); + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_BIN = 'multica'; + +function getBinPath() { + return process.env.MULTICA_CLI_PATH || DEFAULT_BIN; +} + +/** Run a `multica ... --output json` command and parse its stdout. */ +function runMulticaJson(args, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + return new Promise((resolve) => { + let settled = false; + let child; + try { + child = execFile( + getBinPath(), + [...args, '--output', 'json'], + { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, + (err, stdout) => { + if (settled) return; + settled = true; + if (err) { + resolve({ + ok: false, + error: err.killed + ? `multica ${args[0] || ''} timed out after ${timeoutMs}ms` + : (err.message || 'multica command failed'), + }); + return; + } + try { + resolve({ ok: true, data: JSON.parse(stdout) }); + } catch (parseErr) { + resolve({ ok: false, error: `failed to parse multica JSON output: ${parseErr.message}` }); + } + } + ); + } catch (spawnErr) { + if (!settled) { + settled = true; + resolve({ ok: false, error: `failed to spawn multica CLI: ${spawnErr.message}` }); + } + return; + } + child?.on?.('error', (spawnErr) => { + if (settled) return; + settled = true; + resolve({ ok: false, error: `failed to spawn multica CLI: ${spawnErr.message}` }); + }); + }); +} + +/** List issues in the workspace. Returns the raw `{ issues, has_more, ... }` page. */ +function listIssuesPage({ limit = 100, offset = 0, status } = {}) { + const args = ['issue', 'list', '--limit', String(limit), '--offset', String(offset)]; + if (status) args.push('--status', status); + return runMulticaJson(args); +} + +/** Execution history for one issue — array of run records. */ +function getIssueRuns(issueId) { + return runMulticaJson(['issue', 'runs', issueId]); +} + +/** Sub-issues grouped by stage, plus any unstaged children. */ +function getIssueChildren(issueId) { + return runMulticaJson(['issue', 'children', issueId]); +} + +/** All agent runtimes bound to daemons in the workspace. */ +function listRuntimes() { + return runMulticaJson(['runtime', 'list']); +} + +/** All agents in the workspace (id, name, runtime_id, ...). */ +function listAgents() { + return runMulticaJson(['agent', 'list']); +} + +/** Workspace metadata — used to build a canonical deep link back to the issue. */ +function getWorkspace(workspaceId) { + const args = ['workspace', 'get']; + if (workspaceId) args.push(workspaceId); + return runMulticaJson(args); +} + +module.exports = { + runMulticaJson, + listIssuesPage, + getIssueRuns, + getIssueChildren, + listRuntimes, + listAgents, + getWorkspace, +}; diff --git a/lib/multica-telemetry.js b/lib/multica-telemetry.js new file mode 100644 index 0000000..b8b1d4b --- /dev/null +++ b/lib/multica-telemetry.js @@ -0,0 +1,106 @@ +// lib/multica-telemetry.js — orchestrates the CLI adapter + normalization +// into the single payload the API route serves. +// +// Boundary discipline: this module is the only caller of both +// lib/multica-cli.js (I/O) and lib/multica-bridge.js (pure logic). Keeping +// them separate is what makes lib/multica-bridge.js unit-testable without a +// CLI/network dependency. +'use strict'; + +const cli = require('./multica-cli'); +const bridge = require('./multica-bridge'); + +const DEFAULT_ISSUE_PAGE_LIMIT = 100; + +/** + * Fetch every issue in the workspace (single page — the workspace-scale + * assumption is documented in docs/MULTICA_BRIDGE.md's "known limitations"). + */ +async function fetchAllIssues({ limit = DEFAULT_ISSUE_PAGE_LIMIT } = {}) { + const page = await cli.listIssuesPage({ limit, offset: 0 }); + if (!page.ok) return { ok: false, error: page.error, issues: [] }; + return { ok: true, issues: page.data?.issues || [], total: page.data?.total ?? null }; +} + +/** + * Build the full Multica execution telemetry snapshot: one normalized + * execution record per active/recent issue, agents+runtimes resolved to + * names, and a workspace-level deep-link config. + * + * Never throws. Partial failures (e.g. runs lookup fails for one issue) are + * recorded per-issue as `telemetryError` rather than dropped silently or + * shown as healthy — satisfies "failures fail visibly" from the acceptance + * criteria. + */ +async function buildTelemetrySnapshot({ + workspaceId, + appUrl = 'https://multica.ai', + staleThresholdMs, + nowMs = Date.now(), +} = {}) { + const warnings = []; + + const [issuesResult, agentsResult, runtimesResult, workspaceResult] = await Promise.all([ + fetchAllIssues(), + cli.listAgents(), + cli.listRuntimes(), + cli.getWorkspace(workspaceId), + ]); + + if (!issuesResult.ok) { + return { + ok: false, + error: `Could not read Multica issues: ${issuesResult.error}`, + issues: [], + warnings: [issuesResult.error], + generatedAt: new Date(nowMs).toISOString(), + }; + } + + if (!agentsResult.ok) warnings.push(`agent list unavailable: ${agentsResult.error}`); + if (!runtimesResult.ok) warnings.push(`runtime list unavailable: ${runtimesResult.error}`); + if (!workspaceResult.ok) warnings.push(`workspace lookup unavailable: ${workspaceResult.error}`); + + const agentsById = bridge.indexById(agentsResult.ok ? agentsResult.data : []); + const runtimesById = bridge.indexById(runtimesResult.ok ? runtimesResult.data : []); + const workspaceSlug = workspaceResult.ok ? workspaceResult.data?.slug : null; + + const candidateIssues = issuesResult.issues.filter((i) => bridge.isActiveOrRecentIssue(i, nowMs)); + + const records = await Promise.all(candidateIssues.map(async (issue) => { + const [runsResult, childrenResult] = await Promise.all([ + cli.getIssueRuns(issue.id), + cli.getIssueChildren(issue.id), + ]); + + const record = bridge.normalizeIssueExecution({ + issue, + rawRuns: runsResult.ok ? runsResult.data : [], + childrenPayload: childrenResult.ok ? childrenResult.data : null, + agentsById, + runtimesById, + linkConfig: { appUrl, workspaceSlug }, + nowMs, + staleThresholdMs, + }); + + if (!runsResult.ok) { + record.executionState = bridge.EXECUTION_STATES.UNKNOWN; + record.telemetryError = `runs unavailable: ${runsResult.error}`; + } else if (!childrenResult.ok) { + record.telemetryError = `children unavailable: ${childrenResult.error}`; + } + + return record; + })); + + return { + ok: true, + issues: records, + warnings, + generatedAt: new Date(nowMs).toISOString(), + staleThresholdMs: staleThresholdMs ?? bridge.DEFAULT_STALE_THRESHOLD_MS, + }; +} + +module.exports = { fetchAllIssues, buildTelemetrySnapshot }; From a0044b5e1ac25a83486cb302fcf1656e1d8d6f7a Mon Sep 17 00:00:00 2001 From: Joshua Muthumani Date: Sun, 30 Aug 2026 20:35:36 -0700 Subject: [PATCH 3/6] feat(server): expose GET /api/office/multica-issues telemetry endpoint Wires the Multica telemetry bridge (lib/multica-telemetry.js) into the HTTP layer for JOSH-42. Auth-gated via requireAuth, 5s in-memory cache to avoid hammering the `multica` CLI on frequent UI polls (bypassable with ?refresh=1). Failures surface visibly: any error is returned as { ok: false, error, issues: [], warnings: [...] } rather than falling back to a response that looks healthy. --- server.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/server.js b/server.js index 341ed43..91edd9c 100644 --- a/server.js +++ b/server.js @@ -128,6 +128,7 @@ function execHermes(args, timeout = 30000, stdin = null) { // ── Direct data access (no hermes CLI subprocess) ── const { getHermesVersion, getAgentCount, getSessionCount } = require('./lib/hermes'); +const { buildTelemetrySnapshot } = require('./lib/multica-telemetry'); // ── Load HCI config (hci.config.yaml + env overrides) ── const cfg = getConfig(); @@ -3598,6 +3599,37 @@ app.get('/api/office/kanban', requireAuth, (req, res) => { } }); +// ── Office v5: Multica Issue Execution Telemetry ──────────────────────────── +// JOSH-42 — Multica-to-Hermes execution-observability bridge. +// +// Multica stays canonical for issue ownership/status/comments/swarm structure; +// this endpoint is a read-only telemetry mirror over the authenticated +// `multica` CLI's JSON contract (see lib/multica-cli.js, lib/multica-bridge.js, +// docs/MULTICA_BRIDGE.md). No direct state.db integration; no secrets exposed. +let _multicaTelemetryCache = { at: 0, payload: null }; +const MULTICA_TELEMETRY_CACHE_MS = 5000; // avoid hammering the CLI on 2s UI polls + +app.get('/api/office/multica-issues', requireAuth, async (req, res) => { + try { + const now = Date.now(); + const forceRefresh = req.query.refresh === '1'; + if (!forceRefresh && _multicaTelemetryCache.payload && (now - _multicaTelemetryCache.at) < MULTICA_TELEMETRY_CACHE_MS) { + return res.json(_multicaTelemetryCache.payload); + } + + const workspaceId = process.env.MULTICA_WORKSPACE_ID || null; + const appUrl = process.env.MULTICA_APP_URL || 'https://multica.ai'; + const snapshot = await buildTelemetrySnapshot({ workspaceId, appUrl, nowMs: now }); + + const payload = { ok: snapshot.ok, ...snapshot }; + _multicaTelemetryCache = { at: now, payload }; + res.json(payload); + } catch (e) { + // Fail visibly — never fall back to a stale-looking "healthy" response. + res.json({ ok: false, error: e.message, issues: [], warnings: [e.message] }); + } +}); + // List available boards app.get('/api/office/kanban/boards', requireAuth, (req, res) => { try { From 550b7f2d04cefc6ff7a2431b7c7135ce1c436ce1 Mon Sep 17 00:00:00 2001 From: Joshua Muthumani Date: Sun, 30 Aug 2026 20:35:46 -0700 Subject: [PATCH 4/6] feat(office): add Multica Issues tab to the Office view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "🔗 Multica Issues" toggle alongside the existing Kanban view on the Office page (JOSH-42). The new tab polls GET /api/office/multica-issues every 5s and renders a table with: execution-state badges, run id/status, resolved executing agent and bound runtime name, last-activity/heartbeat, swarm/child-count badge for parent issues, and a deep link back to the canonical Multica issue. - src/js/multica-issues-panel.js: panel init/destroy + poll lifecycle, matching the existing Kanban panel's lifecycle pattern. - src/js/pages/office.js: view switcher wiring (kanban <-> multica), hides the per-board controls while the Multica tab is active. - src/css/office.css: state badges and table styling for the new tab. --- src/css/office.css | 105 ++++++++++++++++++ src/js/multica-issues-panel.js | 196 +++++++++++++++++++++++++++++++++ src/js/pages/office.js | 35 ++++++ 3 files changed, 336 insertions(+) create mode 100644 src/js/multica-issues-panel.js diff --git a/src/css/office.css b/src/css/office.css index e757ad2..6b8b576 100644 --- a/src/css/office.css +++ b/src/css/office.css @@ -1252,3 +1252,108 @@ white-space: pre-wrap; font-family: monospace; } + +/* ── Multica Issue Execution Telemetry panel (JOSH-42) ──────────────── */ +.mx-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +.mx-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + font-size: 11px; + font-weight: 600; + color: var(--fg); + letter-spacing: 0.03em; + flex-shrink: 0; +} +.mx-generated-at { + font-weight: 400; + color: var(--fg-muted); + font-size: 10px; +} +.mx-loading, .mx-empty { + padding: 24px; + text-align: center; + color: var(--fg-muted); + font-size: 12px; +} +.mx-error { + padding: 16px; + margin: 12px; + border: 1px solid var(--red); + border-radius: 6px; + background: color-mix(in srgb, var(--red) 10%, transparent); +} +.mx-error-title { font-weight: 600; color: var(--red); font-size: 12px; margin-bottom: 4px; } +.mx-error-detail { font-size: 11px; color: var(--fg-muted); font-family: monospace; } +.mx-warnings { + padding: 6px 12px; + font-size: 11px; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, transparent); + border-bottom: 1px solid var(--border); +} +.mx-table-wrap { overflow: auto; flex: 1; min-height: 0; } +.mx-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} +.mx-table thead th { + position: sticky; + top: 0; + text-align: left; + padding: 6px 10px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + color: var(--fg-muted); + font-weight: 600; + letter-spacing: 0.02em; + z-index: 1; +} +.mx-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.mx-row--stale { background: color-mix(in srgb, var(--red) 6%, transparent); } +.mx-issue-cell { max-width: 240px; } +.mx-issue-id { font-weight: 600; color: var(--fg); } +.mx-issue-title { color: var(--fg-muted); font-size: 10px; } +.mx-issue-status { text-transform: uppercase; font-size: 10px; color: var(--fg-muted); } +.mx-telemetry-error { color: var(--red); font-size: 10px; margin-top: 2px; } +.mx-state-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; +} +.mx-run-id { font-family: monospace; color: var(--fg); } +.mx-run-status { color: var(--fg-muted); font-size: 10px; } +.mx-run-none, .mx-swarm-none { color: var(--fg-subtle); } +.mx-runtime-name { color: var(--fg-muted); font-size: 10px; } +.mx-swarm-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; +} +.mx-link { color: var(--blue); text-decoration: none; font-size: 10px; } +.mx-link:hover { text-decoration: underline; } + +.mx--muted { color: var(--fg-muted); background: color-mix(in srgb, var(--fg-muted) 12%, transparent); } +.mx--blue { color: var(--blue); background: color-mix(in srgb, var(--blue) 15%, transparent); } +.mx--green { color: var(--green); background: color-mix(in srgb, var(--green) 15%, transparent); } +.mx--amber { color: var(--amber); background: color-mix(in srgb, var(--amber) 15%, transparent); } +.mx--pink { color: var(--pink); background: color-mix(in srgb, var(--pink) 15%, transparent); } +.mx--red { color: var(--red); background: color-mix(in srgb, var(--red) 15%, transparent); } diff --git a/src/js/multica-issues-panel.js b/src/js/multica-issues-panel.js new file mode 100644 index 0000000..d4c2cac --- /dev/null +++ b/src/js/multica-issues-panel.js @@ -0,0 +1,196 @@ +// src/js/multica-issues-panel.js — Multica Issue Execution Telemetry panel +// (JOSD-42 / JOSH-42: Multica-to-Hermes execution-observability bridge). +// +// Multica remains canonical for issue ownership/status/comments/swarm +// structure. This panel is a read-only mirror of `/api/office/multica-issues` +// (server.js → lib/multica-telemetry.js → lib/multica-bridge.js), which in +// turn wraps the authenticated `multica` CLI's JSON contract. No secrets, no +// direct state.db access from here. + +let pollTimer = null; +let containerId = null; +let fetchInFlight = false; +let lastSnapshot = null; + +const STATE_META = { + unassigned: { emoji: '➖', label: 'Unassigned', cls: 'mx--muted' }, + assigned_idle: { emoji: '🧍', label: 'Assigned · Idle', cls: 'mx--blue' }, + queued: { emoji: '⏳', label: 'Queued', cls: 'mx--blue' }, + running: { emoji: '🟢', label: 'Running', cls: 'mx--green' }, + stale: { emoji: '💤', label: 'Stale', cls: 'mx--red' }, + waiting_review: { emoji: '👁️', label: 'Waiting Review', cls: 'mx--pink' }, + blocked: { emoji: '⚠️', label: 'Blocked', cls: 'mx--red' }, + succeeded: { emoji: '✅', label: 'Succeeded', cls: 'mx--green' }, + failed: { emoji: '❌', label: 'Failed', cls: 'mx--red' }, + cancelled: { emoji: '🚫', label: 'Cancelled', cls: 'mx--muted' }, + unknown: { emoji: '❓', label: 'Unknown', cls: 'mx--red' }, +}; + +// Execution states shown in the active-worker view by default; done/cancelled +// issues only appear here briefly (see isActiveOrRecentIssue server-side), +// keeping completed history queryable separately without polluting this view. +const ACTIVE_ORDER = ['stale', 'running', 'queued', 'waiting_review', 'blocked', 'failed', 'assigned_idle', 'unassigned', 'unknown', 'succeeded', 'cancelled']; + +function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function trunc(s, len) { return s && s.length > len ? s.slice(0, len) + '…' : (s || ''); } + +function relTimeFromIso(iso) { + if (!iso) return '—'; + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return '—'; + const deltaMs = Date.now() - ms; + if (deltaMs < 0) return 'just now'; + const m = Math.floor(deltaMs / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +export async function initMulticaPanel(contId) { + containerId = contId; + destroyMulticaPanel(); + renderShell(); + await fetchAndRender(); +} + +export function destroyMulticaPanel() { + stopMulticaPoll(); + lastSnapshot = null; +} + +export function startMulticaPoll(interval = 5000) { + stopMulticaPoll(); + pollTimer = setInterval(fetchAndRender, interval); +} + +export function stopMulticaPoll() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } +} + +function renderShell() { + const root = document.getElementById(containerId); + if (!root) return; + root.innerHTML = ` +
+
+ 🔗 Multica Issues — Execution Telemetry + +
+
⟳ Loading Multica telemetry…
+
`; +} + +async function fetchAndRender() { + if (fetchInFlight) return; + fetchInFlight = true; + try { + const res = await fetch('/api/office/multica-issues'); + const data = await res.json(); + lastSnapshot = data; + patchBody(data); + } catch (e) { + patchBody({ ok: false, error: e.message, issues: [], warnings: [] }); + } finally { + fetchInFlight = false; + } +} + +function patchBody(data) { + const body = document.getElementById('mx-body'); + const genAt = document.getElementById('mx-generated-at'); + if (!body) return; + + if (genAt) genAt.textContent = data.generatedAt ? `updated ${relTimeFromIso(data.generatedAt)}` : ''; + + if (!data.ok) { + // Fail visibly — never render a stale/empty table as if it were healthy. + body.innerHTML = ` +
+
⚠️ Multica telemetry unavailable
+
${esc(data.error || 'unknown error')}
+
`; + return; + } + + const issues = Array.isArray(data.issues) ? data.issues : []; + const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; + + const sorted = [...issues].sort((a, b) => { + const ai = ACTIVE_ORDER.indexOf(a.executionState); + const bi = ACTIVE_ORDER.indexOf(b.executionState); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + }); + + const warnBanner = warnings.length + ? `
⚠️ ${warnings.map((w) => esc(w)).join(' · ')}
` + : ''; + + if (!sorted.length) { + body.innerHTML = `${warnBanner}
No active or recently active Multica issues.
`; + return; + } + + body.innerHTML = `${warnBanner}
${renderTable(sorted)}
`; +} + +function renderTable(records) { + const rows = records.map(renderRow).join(''); + return ` + + + + + + + + + + + + + + ${rows} +
IssueStatusExecutionRunAgent / RuntimeLast ActivitySwarm
`; +} + +function renderRow(rec) { + const meta = STATE_META[rec.executionState] || STATE_META.unknown; + const run = rec.run; + const swarm = rec.swarm || { totalChildren: 0, isSwarm: false, hasActiveChildren: false, byStatus: {} }; + + const swarmBadge = swarm.isSwarm + ? ` + 🐝 ${swarm.totalChildren}${swarm.hasActiveChildren ? ' active' : ''} + ` + : ''; + + const runCell = run + ? `#${esc(String(run.id).slice(-8))} ${esc(run.status)}` + : ''; + + const agentCell = run && (run.agentName || run.runtimeName) + ? `${esc(run.agentName || '—')}
${esc(run.runtimeName || '')}
` + : ''; + + const errCell = rec.telemetryError + ? `
⚠️ telemetry gap
` + : ''; + + return ` + + +
${esc(rec.identifier || '—')}
+
${esc(trunc(rec.title || '', 46))}
+ ${errCell} + + ${esc(rec.issueStatus || '—')} + ${meta.emoji} ${meta.label} + ${runCell} + ${agentCell} + ${esc(relTimeFromIso(rec.lastActivityAt))} + ${swarmBadge} + ${rec.deepLink ? `Open ↗` : ''} + `; +} diff --git a/src/js/pages/office.js b/src/js/pages/office.js index 6a03053..b491453 100644 --- a/src/js/pages/office.js +++ b/src/js/pages/office.js @@ -1,7 +1,13 @@ import { destroyKanbanBoard, initKanbanBoard, startKanbanPoll, stopKanbanPoll } from '../core/state.js'; +import { destroyMulticaPanel, initMulticaPanel, startMulticaPoll, stopMulticaPoll } from '../multica-issues-panel.js'; + +let activeView = 'kanban'; + function stopOfficeAutoRefresh() { stopKanbanPoll(); destroyKanbanBoard(); + stopMulticaPoll(); + destroyMulticaPanel(); } async function loadOffice(container) { @@ -13,6 +19,10 @@ async function loadOffice(container) {
Kanban board — live task visualization
+ + +
+
@@ -23,9 +33,34 @@ async function loadOffice(container) {
`; + activeView = 'kanban'; // Init PixiJS Kanban in the root div await initKanbanBoard('office-kanban-root', 'main'); startKanbanPoll(30000); } +async function switchOfficeView(view) { + if (view === activeView) return; + activeView = view; + + document.querySelectorAll('#office-controls .btn').forEach((b) => { + b.classList.toggle('btn-active', b.dataset.view === view); + }); + const boardControls = document.getElementById('office-board-controls'); + if (boardControls) boardControls.style.display = view === 'kanban' ? '' : 'none'; + + if (view === 'kanban') { + stopMulticaPoll(); + destroyMulticaPanel(); + await initKanbanBoard('office-kanban-root', 'main'); + startKanbanPoll(30000); + } else { + stopKanbanPoll(); + destroyKanbanBoard(); + await initMulticaPanel('office-kanban-root'); + startMulticaPoll(5000); + } +} +window.switchOfficeView = switchOfficeView; + export { stopOfficeAutoRefresh, loadOffice }; From 370068b2e0de421fd4c9a25239f6f71d35e3c0d2 Mon Sep 17 00:00:00 2001 From: Joshua Muthumani Date: Sun, 30 Aug 2026 20:36:01 -0700 Subject: [PATCH 5/6] test(multica): add unit tests for the telemetry bridge 29 new unit tests covering lib/multica-bridge.js: run normalization, active/terminal run selection, queued/stale detection (heartbeat-age threshold), swarm aggregation, execution-state classification across the full state matrix, deep-link construction, agent/runtime name resolution, and active/recent-issue history-window filtering. npm test: 42/42 passing (29 new + 13 pre-existing). --- test/multica-bridge.test.js | 262 ++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 test/multica-bridge.test.js diff --git a/test/multica-bridge.test.js b/test/multica-bridge.test.js new file mode 100644 index 0000000..15fcd3c --- /dev/null +++ b/test/multica-bridge.test.js @@ -0,0 +1,262 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + EXECUTION_STATES, + normalizeRun, + sortRunsDesc, + pickActiveRun, + pickLastTerminalRun, + isQueued, + isStale, + summarizeSwarm, + resolveAgentName, + resolveRuntimeName, + buildIssueDeepLink, + classifyExecutionState, + normalizeIssueExecution, + indexById, + isActiveOrRecentIssue, +} = require('../lib/multica-bridge'); + +// ── normalizeRun ───────────────────────────────────────────────────────────── +test('normalizeRun returns null for non-objects', () => { + assert.strictEqual(normalizeRun(null), null); + assert.strictEqual(normalizeRun('abc'), null); +}); + +test('normalizeRun extracts basic fields with defaults', () => { + const raw = { id: 'task-1', status: 'running' }; + const norm = normalizeRun(raw); + assert.strictEqual(norm.id, 'task-1'); + assert.strictEqual(norm.status, 'running'); + assert.strictEqual(norm.attempt, 1); +}); + +test('normalizeRun parses timestamps', () => { + const now = new Date().toISOString(); + const raw = { id: 't1', created_at: now, started_at: now, completed_at: now }; + const norm = normalizeRun(raw); + assert.strictEqual(norm.createdAt, now); + assert.strictEqual(norm.startedAt, now); + assert.strictEqual(norm.completedAt, now); +}); + +// ── sortRunsDesc ───────────────────────────────────────────────────────────── +// sortRunsDesc/pickActiveRun/pickLastTerminalRun operate on *normalized* runs +// (camelCase createdAt), i.e. the output of normalizeRun — not raw CLI records. +test('sortRunsDesc sorts newest-created-first', () => { + const r1 = { id: '1', createdAt: '2026-01-02T00:00:00Z' }; + const r2 = { id: '2', createdAt: '2026-01-01T00:00:00Z' }; + const r3 = { id: '3', createdAt: '2026-01-03T00:00:00Z' }; + const sorted = sortRunsDesc([r1, r2, r3]); + assert.strictEqual(sorted[0].id, '3'); + assert.strictEqual(sorted[1].id, '1'); + assert.strictEqual(sorted[2].id, '2'); +}); + +// ── pickActiveRun ──────────────────────────────────────────────────────────── +test('pickActiveRun returns the newest running run', () => { + const runs = [ + { id: 'r1', status: 'running', createdAt: '2026-01-01T00:00:00Z' }, + { id: 'r2', status: 'completed', createdAt: '2026-01-02T00:00:00Z' }, + { id: 'r3', status: 'running', createdAt: '2026-01-03T00:00:00Z' }, + ]; + const active = pickActiveRun(runs); + assert.strictEqual(active.id, 'r3'); +}); + +test('pickActiveRun returns null when no running runs', () => { + const runs = [ + { id: 'r1', status: 'completed', createdAt: '2026-01-01T00:00:00Z' }, + { id: 'r2', status: 'failed', createdAt: '2026-01-02T00:00:00Z' }, + ]; + const active = pickActiveRun(runs); + assert.strictEqual(active, null); +}); + +// ── pickLastTerminalRun ───────────────────────────────────────────────────── +test('pickLastTerminalRun returns the newest terminal run', () => { + const runs = [ + { id: 'r1', status: 'completed', createdAt: '2026-01-01T00:00:00Z' }, + { id: 'r2', status: 'failed', createdAt: '2026-01-03T00:00:00Z' }, + { id: 'r3', status: 'running', createdAt: '2026-01-02T00:00:00Z' }, + ]; + const term = pickLastTerminalRun(runs); + assert.strictEqual(term.id, 'r2'); +}); + +// ── isQueued ──────────────────────────────────────────────────────────────── +test('isQueued returns true when activeRun has no startedAt', () => { + const run = { id: 'r1', status: 'running', startedAt: null }; + assert.strictEqual(isQueued(run), true); +}); + +test('isQueued returns false when activeRun has startedAt', () => { + const run = { id: 'r1', status: 'running', startedAt: '2026-01-01T00:00:00Z' }; + assert.strictEqual(isQueued(run), false); +}); + +// ── isStale ────────────────────────────────────────────────────────────────── +test('isStale returns false for a fresh running run', () => { + const now = Date.parse('2026-01-01T00:00:00Z'); + const run = { id: 'r1', status: 'running', startedAt: '2026-01-01T00:00:00Z' }; + const issue = { last_activity_at: '2026-01-01T00:00:00Z' }; + assert.strictEqual(isStale(run, issue.last_activity_at, now, 15 * 60 * 1000), false); +}); + +test('isStale returns true when no heartbeat for > threshold', () => { + const now = Date.parse('2026-01-01T00:20:00Z'); + const run = { id: 'r1', status: 'running', startedAt: '2026-01-01T00:00:00Z' }; + const issue = { last_activity_at: '2026-01-01T00:00:00Z' }; + assert.strictEqual(isStale(run, issue.last_activity_at, now, 15 * 60 * 1000), true); +}); + +test('isStale returns false for a run that has not started yet (queued, not stale)', () => { + // isQueued() short-circuits: a dispatched-but-not-started run is QUEUED, + // never STALE — staleness only applies once execution has actually begun. + const now = Date.parse('2026-01-01T00:00:00Z'); + const run = { id: 'r1', status: 'running' }; // no startedAt => queued + assert.strictEqual(isStale(run, null, now, 15 * 60 * 1000), false); +}); + +// ── summarizeSwarm ────────────────────────────────────────────────────────── +test('summarizeSwarm counts children by status', () => { + const payload = { + stages: [ + { issues: [ + { status: 'todo' }, + { status: 'todo' }, + { status: 'in_progress' }, + ]}, + ], + unstaged: [{ status: 'done' }], + }; + const summary = summarizeSwarm(payload); + assert.strictEqual(summary.totalChildren, 4); + assert.deepStrictEqual(summary.byStatus, { todo: 2, in_progress: 1, done: 1 }); + assert.strictEqual(summary.hasActiveChildren, true); + assert.strictEqual(summary.isSwarm, true); +}); + +test('summarizeSwarm returns empty summary for null/empty', () => { + const s1 = summarizeSwarm(null); + assert.strictEqual(s1.totalChildren, 0); + const s2 = summarizeSwarm({}); + assert.strictEqual(s2.totalChildren, 0); +}); + +// ── resolveAgentName / resolveRuntimeName ──────────────────────────────────── +test('resolveAgentName returns agent name or falls back to id', () => { + const byId = { 'a1': { name: 'Hermes' }, 'a2': { name: 'Codex' } }; + assert.strictEqual(resolveAgentName('a1', byId), 'Hermes'); + assert.strictEqual(resolveAgentName('unknown', byId), 'unknown'); + assert.strictEqual(resolveAgentName(null, {}), null); +}); + +test('resolveRuntimeName prefers custom_name, falls back to name, then id', () => { + const byId = { + 'r1': { name: 'Venom.local' }, + 'r2': { name: 'Hermes', custom_name: 'My Hermes' }, + }; + assert.strictEqual(resolveRuntimeName('r1', byId), 'Venom.local'); + assert.strictEqual(resolveRuntimeName('r2', byId), 'My Hermes'); + assert.strictEqual(resolveRuntimeName('unknown', byId), 'unknown'); +}); + +// ── buildIssueDeepLink ────────────────────────────────────────────────────── +test('buildIssueDeepLink constructs the canonical issue URL', () => { + const link = buildIssueDeepLink({ + appUrl: 'https://multica.ai', + workspaceSlug: 'josh-hobby', + identifier: 'JOSH-42', + }); + assert.strictEqual(link, 'https://multica.ai/josh-hobby/issues/JOSH-42'); +}); + +test('buildIssueDeepLink returns null when any part is missing', () => { + assert.strictEqual(buildIssueDeepLink({ appUrl: 'x', workspaceSlug: 'y', identifier: null }), null); + assert.strictEqual(buildIssueDeepLink({ appUrl: null, workspaceSlug: 'y', identifier: 'z' }), null); +}); + +// ── classifyExecutionState ─────────────────────────────────────────────────── +test('classifyExecutionState respects issue status first', () => { + assert.strictEqual(classifyExecutionState({ issue: { status: 'done' } }), EXECUTION_STATES.SUCCEEDED); + assert.strictEqual(classifyExecutionState({ issue: { status: 'cancelled' } }), EXECUTION_STATES.CANCELLED); + assert.strictEqual(classifyExecutionState({ issue: { status: 'in_review' } }), EXECUTION_STATES.WAITING_REVIEW); + assert.strictEqual(classifyExecutionState({ issue: { status: 'blocked' } }), EXECUTION_STATES.BLOCKED); +}); + +test('classifyExecutionState picks RUNNING for fresh active run', () => { + const active = { status: 'running', startedAt: '2026-01-01T00:00:00Z' }; + assert.strictEqual(classifyExecutionState({ issue: { status: 'in_progress' }, activeRun: active, stale: false }), EXECUTION_STATES.RUNNING); +}); + +test('classifyExecutionState picks STALE for stale active run', () => { + const active = { status: 'running', startedAt: '2026-01-01T00:00:00Z' }; + assert.strictEqual(classifyExecutionState({ issue: { status: 'in_progress' }, activeRun: active, stale: true }), EXECUTION_STATES.STALE); +}); + +test('classifyExecutionState picks QUEUED for dispatched but not started', () => { + const active = { status: 'running', startedAt: null }; + assert.strictEqual(classifyExecutionState({ issue: { status: 'in_progress' }, activeRun: active, stale: false }), EXECUTION_STATES.QUEUED); +}); + +test('classifyExecutionState falls back to ASSIGNED_IDLE or UNASSIGNED', () => { + assert.strictEqual(classifyExecutionState({ issue: { status: 'in_progress', assignee_type: 'agent', assignee_id: 'a1' } }), EXECUTION_STATES.ASSIGNED_IDLE); + assert.strictEqual(classifyExecutionState({ issue: { status: 'todo' } }), EXECUTION_STATES.UNASSIGNED); +}); + +// ── normalizeIssueExecution ────────────────────────────────────────────────── +test('normalizeIssueExecution runs the full pipeline', () => { + const now = Date.parse('2026-01-01T00:05:00Z'); // 5 minutes after run start — within threshold + const issue = { id: 'i1', identifier: 'JOSH-42', title: 'Test', status: 'in_progress', last_activity_at: '2026-01-01T00:05:00Z' }; + const rawRuns = [{ + id: 'r1', + status: 'running', + created_at: '2026-01-01T00:00:00Z', + started_at: '2026-01-01T00:00:00Z', + agent_id: 'a1', + runtime_id: 'rt1', + }]; + const children = { stages: [], unstaged: [] }; + const agents = { 'a1': { name: 'Hermes' } }; + const runtimes = { 'rt1': { custom_name: 'Hermes', name: 'Hermes' } }; + + const norm = normalizeIssueExecution({ issue, rawRuns, childrenPayload: children, agentsById: agents, runtimesById: runtimes, nowMs: now }); + assert.strictEqual(norm.executionState, EXECUTION_STATES.RUNNING); + assert.strictEqual(norm.run.agentName, 'Hermes'); + assert.strictEqual(norm.run.runtimeName, 'Hermes'); + assert.strictEqual(norm.swarm.isSwarm, false); + assert.strictEqual(norm.isStale, false); +}); + +// ── indexById ──────────────────────────────────────────────────────────────── +test('indexById returns an object keyed by id', () => { + const arr = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }]; + const byId = indexById(arr); + assert.strictEqual(byId.a.name, 'A'); + assert.strictEqual(byId.b.name, 'B'); +}); + +test('indexById returns {} for null/empty', () => { + assert.deepStrictEqual(indexById(null), {}); + assert.deepStrictEqual(indexById([]), {}); +}); + +// ── isActiveOrRecentIssue ──────────────────────────────────────────────────── +test('isActiveOrRecentIssue includes open issues', () => { + const issue = { status: 'in_progress' }; + assert.strictEqual(isActiveOrRecentIssue(issue), true); +}); + +test('isActiveOrRecentIssue includes recently completed/cancelled', () => { + const now = Date.parse('2026-01-01T00:00:00Z'); + const issue = { status: 'done', last_activity_at: '2026-01-01T00:00:00Z' }; + assert.strictEqual(isActiveOrRecentIssue(issue, now, 15 * 60 * 1000), true); +}); + +test('isActiveOrRecentIssue excludes older terminal issues', () => { + const now = Date.parse('2026-01-01T00:20:00Z'); + const issue = { status: 'done', last_activity_at: '2026-01-01T00:00:00Z' }; + assert.strictEqual(isActiveOrRecentIssue(issue, now, 15 * 60 * 1000), false); +}); From eab3e326e905691a8823b53449807294ae997caa Mon Sep 17 00:00:00 2001 From: Joshua Muthumani Date: Sun, 30 Aug 2026 20:36:07 -0700 Subject: [PATCH 6/6] docs(multica): document the telemetry bridge contract and limitations Adds docs/MULTICA_BRIDGE.md: the integration contract and data model for the Multica-to-Hermes execution-observability bridge (JOSH-42), the execution-state model, and known limitations (single-page issue listing, issue-level last_activity_at used as a heartbeat proxy in lieu of a lower-level per-run heartbeat, 5s polling cache instead of push). --- docs/MULTICA_BRIDGE.md | 267 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 docs/MULTICA_BRIDGE.md diff --git a/docs/MULTICA_BRIDGE.md b/docs/MULTICA_BRIDGE.md new file mode 100644 index 0000000..e2c2f85 --- /dev/null +++ b/docs/MULTICA_BRIDGE.md @@ -0,0 +1,267 @@ +# Multica-to-Hermes Execution Telemetry Bridge + +**Status:** first vertical slice, implemented and verified live against the +real Multica API on 2026-08-31 (workspace `Josh Hobby` / `josh-hobby`). +Tracks issue: `JOSH-42`. + +## Product decision (unchanged by this slice) + +Multica remains canonical for issue ownership, status, comments, and swarm +structure. Hermes/HCI is a **read-only, engine-level operational mirror**: +runtime, active execution, heartbeats, and last-activity. This bridge does +**not** create a second source of truth — it never writes to Multica, and it +is safe to delete without losing any Multica state. + +## Architecture + +``` +┌─────────────────────┐ shells out to ┌──────────────────┐ +│ lib/multica-cli.js │ ───────────────────► │ `multica` CLI │ +│ (I/O boundary) │ --output json only │ (authenticated) │ +└──────────┬───────────┘ └──────────────────┘ + │ raw JSON (issues, runs, children, agents, runtimes, workspace) + ▼ +┌──────────────────────┐ +│ lib/multica-bridge.js │ pure functions, no I/O — state mapping, +│ (normalization) │ stale detection, swarm aggregation +└──────────┬────────────┘ + │ normalized execution records + ▼ +┌────────────────────────┐ +│ lib/multica-telemetry.js│ orchestrates cli + bridge into one snapshot +└──────────┬───────────────┘ + │ + ▼ + GET /api/office/multica-issues (server.js, requireAuth, 5s cache) + │ + ▼ + src/js/multica-issues-panel.js (Office page, "🔗 Multica Issues" tab) +``` + +This 3-layer split (I/O adapter → pure normalization → orchestration) exists +specifically so `lib/multica-bridge.js` — state mapping, stale detection, +swarm aggregation — is unit-testable without spawning the CLI or touching the +network. See `test/multica-bridge.test.js` (29 tests, all passing). + +## Integration contract + +**Only `lib/multica-cli.js` shells out to `multica`.** Every call uses +`--output json` and parses the CLI's JSON contract — never human-formatted +table output. Calls used: + +| CLI command | Purpose | +|---|---| +| `multica issue list --output json --limit 100` | Enumerate issues (single page — see Known Limitations) | +| `multica issue runs --output json` | Execution history for one issue: `status` (`running`/`completed`/`failed`/`cancelled`), `agent_id`, `runtime_id`, `started_at`, `completed_at` | +| `multica issue children --output json` | Sub-issues grouped by stage — swarm detection | +| `multica agent list --output json` | Resolve `agent_id` → agent name | +| `multica runtime list --output json` | Resolve `runtime_id` → runtime/device name (`custom_name` preferred, e.g. "Venom") | +| `multica workspace get --output json` | Resolve workspace `slug` for building the deep link | + +No direct `state.db` access, no scraping of table output, no secrets read +from the CLI's config — auth is whatever `multica auth status` already has +configured on the host running HCI (the same identity as any other `multica` +invocation on that machine). + +## Execution state model + +`lib/multica-bridge.js`'s `classifyExecutionState()` is the single place that +derives the display state — every caller (API, UI, tests) goes through it: + +| State | Meaning | +|---|---| +| `unassigned` | No member/agent/squad owns the issue | +| `assigned_idle` | Owned by an agent/squad, no run currently active | +| `queued` | A run was dispatched but has not started (`started_at` is null) | +| `running` | A run is executing and its heartbeat proxy is fresh | +| `stale` | A run claims to be running, but no activity for > threshold (default 15 min) — **never** reported as healthy `running` | +| `waiting_review` | Issue status is `in_review`, no active run | +| `blocked` | Issue status is `blocked`, no active run | +| `succeeded` | Issue status is `done` (Multica status is canonical) | +| `failed` | Issue open, no active run, most recent run `failed` | +| `cancelled` | Issue status `cancelled`, or most recent run `cancelled` | +| `unknown` | Telemetry could not be read for this issue (CLI/API error) — flagged, never presented as healthy | + +**Stale detection.** Multica's CLI does not expose a lower-level per-run +heartbeat, so `issue.last_activity_at` (bumped by comments, run messages, and +status changes) is used as the best available heartbeat proxy. A `running` +run whose `max(last_activity_at, started_at)` is older than +`staleThresholdMs` (default 15 minutes) is reclassified `stale`. A run with +no `started_at` at all is `queued`, not stale — staleness only applies once +execution has actually begun. + +**Swarm detection.** `summarizeSwarm()` reads `multica issue children`, +counts by status, and flags `hasActiveChildren` when any child is +`in_progress`/`todo`/`in_review`/`blocked`. `isSwarm` is true whenever a +parent has one or more children — this is how a parent issue's swarm +activity is surfaced without a separate "squad" concept in the UI. + +**History preservation.** `isActiveOrRecentIssue()` includes every non-terminal +issue plus terminal (`done`/`cancelled`) issues whose `last_activity_at` is +within the last 15 minutes. Older completed/cancelled issues fall out of this +view automatically — they remain fully queryable in Multica itself +(`multica issue list --status done`), satisfying "preserve history without +polluting the active view" without building a second history store in HCI. + +## API + +`GET /api/office/multica-issues` (session auth required, same as the rest of +`/api/office/*`). + +```json +{ + "ok": true, + "generatedAt": "2026-08-31T02:40:00.000Z", + "staleThresholdMs": 900000, + "warnings": [], + "issues": [ + { + "issueId": "01a0...", + "identifier": "JOSH-42", + "title": "Expose Multica Issue execution, agent, and swarm telemetry in Hermes", + "issueStatus": "in_progress", + "assigneeType": "agent", + "assigneeId": "958229c6-...", + "executionState": "running", + "isStale": false, + "run": { + "id": "01a0...", + "status": "running", + "agentId": "958229c6-...", + "agentName": "Implementation Engineer", + "runtimeId": "17b5c82c-...", + "runtimeName": "Venom", + "startedAt": "2026-08-31T02:07:...Z", + "completedAt": null, + "createdAt": "2026-08-31T02:07:...Z", + "error": null + }, + "lastActivityAt": "2026-08-31T02:40:...Z", + "swarm": { "totalChildren": 3, "byStatus": {"backlog": 3}, "hasActiveChildren": false, "isSwarm": true }, + "deepLink": "https://multica.ai/josh-hobby/issues/JOSH-42", + "parentIssueId": null + } + ] +} +``` + +On failure (CLI unavailable, auth expired, etc.), the endpoint returns +`{ ok: false, error, issues: [], warnings: [...] }` with HTTP 200 (the panel +renders an explicit "⚠️ Multica telemetry unavailable" error state — it never +silently falls back to a stale-looking "healthy" table). Per-issue partial +failures (e.g. `issue runs` failing for one issue while the rest succeed) are +recorded as `telemetryError` on that record and force `executionState: +"unknown"`, rather than being dropped or shown as healthy. + +**Caching.** The route caches the full snapshot for 5 seconds +(`MULTICA_TELEMETRY_CACHE_MS` in `server.js`) to avoid hammering the CLI when +the UI polls every 5s. Pass `?refresh=1` to bypass the cache. + +## UI + +Office page (`src/js/pages/office.js`) gained a second tab next to the +existing PixiJS Kanban board: **🔗 Multica Issues**. Switching tabs stops the +other view's polling — they never both poll simultaneously. +`src/js/multica-issues-panel.js` renders a sortable table (active states +first: stale → running → queued → waiting_review → blocked → failed → +assigned_idle → unassigned → unknown → succeeded → cancelled), each row +showing the state badge, run id/status, resolved agent/runtime name, last +activity, swarm badge (child count + active indicator), and an "Open ↗" deep +link back to the canonical Multica issue. Polls every 5s while the tab is +active. + +## Auth / secrets handling + +No new secrets are introduced. The bridge relies entirely on the `multica` +CLI's own stored auth token (`~/.multica/config.json` on whichever host runs +`multica`, e.g. via HCI's `docker exec` context or a bound daemon) — HCI +never reads or forwards that token itself; it only reads the CLI's stdout. +`MULTICA_WORKSPACE_ID` and `MULTICA_APP_URL` (used only to build the +human-facing deep link) are read from HCI's own process environment, with no +credential material in either. + +## Known limitations / assumptions + +- **Single-page issue listing.** `fetchAllIssues()` reads one page + (`--limit 100`, the CLI's per-page cap) and does not paginate further. On + workspaces with more than 100 issues where the active/recent ones fall + outside that first page, this bridge would miss them. Given the current + workspace's issue count (< 100 seen at implementation time), this was + judged an acceptable first-slice limit — the fix is a straightforward + `--offset` loop in `fetchAllIssues()` if/when it's needed. +- **No per-run heartbeat.** Stale detection uses issue-level + `last_activity_at` as a proxy, not a lower-level per-run heartbeat (Multica + does not expose one over the CLI contract investigated here). A long-running + task that produces no comments/run-messages for 15+ minutes will show as + `stale` even if it is still healthily working — this is a deliberate + fail-safe bias (never claim healthy without evidence), not a bug. +- **Polling, not push.** The UI polls every 5s and the API caches for 5s; + there is no webhook/SSE push from Multica in this slice. This matches the + existing Office Kanban panel's own polling pattern (30s) and was chosen for + consistency and to avoid a new event-plumbing dependency in the first slice. + A future slice could push updates over the same `/ws` HCI already runs. +- **Deep link construction.** `buildIssueDeepLink()` assumes Multica's issue + URL is `{appUrl}/{workspaceSlug}/issues/{identifier}` (confirmed from + Multica's own GitHub-integration docs, which document `MUL-123`-style + identifiers used in exactly this path shape). If Multica's URL scheme + changes, only this one function needs updating. +- **No E2E browser test added.** `test/office-visualization.spec.js` + (Playwright) exercises the existing Kanban panel against a running HCI + server; extending it to the new Multica tab requires a running server with + configured auth secrets, which is out of scope for this slice's automated + test run (documented here rather than skipped silently). The live + `buildTelemetrySnapshot()` call against the real Multica workspace (see + Test Evidence below) is the closest available proof this actually works + end-to-end. + +## Test evidence + +``` +$ npm test +... +ℹ tests 42 +ℹ pass 42 +ℹ fail 0 +``` + +29 of those 42 are new, in `test/multica-bridge.test.js`, covering: +normalization (`normalizeRun`), sorting/selection (`sortRunsDesc`, +`pickActiveRun`, `pickLastTerminalRun`), queued/stale detection (`isQueued`, +`isStale`), swarm aggregation (`summarizeSwarm`), name resolution +(`resolveAgentName`, `resolveRuntimeName`), deep-link construction +(`buildIssueDeepLink`), the full state-mapping matrix +(`classifyExecutionState`), the end-to-end normalization pipeline +(`normalizeIssueExecution`), and history-window filtering +(`isActiveOrRecentIssue`). + +**Live verification** (2026-08-31, real Multica workspace, no mocks): + +``` +$ node -e "require('./lib/multica-telemetry').buildTelemetrySnapshot({...}).then(...)" +ok: true +warnings: [] +issue count: 19 +running/stale: [ + { id: 'JOSH-44', state: 'running', agent: 'Security Architect', runtime: 'Venom' }, + { id: 'JOSH-43', state: 'stale', agent: 'Architecture Director', runtime: 'Venom' }, + { id: 'JOSH-42', state: 'running', agent: 'Implementation Engineer', runtime: 'Venom' }, + { id: 'JOSH-41', state: 'running', agent: 'SDLC Pipeline Coordinator', runtime: 'Venom' } +] +swarms: [ { id: 'JOSH-42', swarm: { totalChildren: 3, hasActiveChildren: true, isSwarm: true } } ] +``` + +This is JOSH-42 (this very issue) observing itself: correctly shown +`running`, with `Implementation Engineer` as the executing agent and `Venom` +as the bound runtime, and its own children correctly detected as an active +swarm. JOSH-43's run is correctly downgraded from `running` to `stale` by the +heartbeat-age check, demonstrating the "run failures/stale heartbeats fail +visibly" acceptance criterion against real, uncontrolled production data — +not a fixture. + +**Not run:** `npx vite build` (frontend bundling) — fails in this +environment with a pre-existing, unrelated error (`rolldown-binding.darwin-universal.node` +missing; verified with `git stash` that the failure exists on `main` before +any of this slice's changes). `node --check` was run against every new/edited +`.js` file instead (all pass) as the available substitute for a type/syntax +gate in this repo (plain JS, no TypeScript, no dedicated lint config beyond +`npm test`).