From feac9541cebc48c7a55b461cb648dda348f4f069 Mon Sep 17 00:00:00 2001 From: mira-zennai Date: Fri, 8 May 2026 07:48:34 +0900 Subject: [PATCH 1/6] feat: Mira workflow console read-only MVP --- lib/workflows.js | 242 +++++++++++++++++++++++++++++++++++++++++ server.js | 41 ++++++- src/css/components.css | 75 +++++++++++++ src/index.html | 2 + src/js/main.js | 153 ++++++++++++++++++++++++++ test/workflows.test.js | 86 +++++++++++++++ 6 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 lib/workflows.js create mode 100644 test/workflows.test.js diff --git a/lib/workflows.js b/lib/workflows.js new file mode 100644 index 0000000..3a9addd --- /dev/null +++ b/lib/workflows.js @@ -0,0 +1,242 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const yaml = require('js-yaml'); + +const DEFAULT_REPO_CANDIDATES = [ + path.join(os.homedir(), 'worktrees', 'MiraRepo_runtime_main'), + path.join(os.homedir(), 'repos', 'MiraRepo'), +]; + +function resolveMiraRepoDir(explicitDir = process.env.MIRA_REPO_DIR) { + const candidates = [explicitDir, ...DEFAULT_REPO_CANDIDATES].filter(Boolean); + for (const candidate of candidates) { + const resolved = path.resolve(candidate); + if (fs.existsSync(path.join(resolved, 'docs', 'workflows'))) return resolved; + } + return path.resolve(candidates[0] || DEFAULT_REPO_CANDIDATES[0]); +} + +function toPosixRelative(root, filePath) { + return path.relative(root, filePath).split(path.sep).join('/'); +} + +function listFiles(dir, predicate = () => true) { + try { + return fs.readdirSync(dir) + .map((name) => path.join(dir, name)) + .filter((filePath) => { + try { return fs.statSync(filePath).isFile() && predicate(filePath); } + catch { return false; } + }) + .sort(); + } catch { + return []; + } +} + +function readYamlFile(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = yaml.load(raw) || {}; + return { raw, data }; +} + +function getNested(obj, pathParts) { + let current = obj; + for (const part of pathParts) { + if (!current || typeof current !== 'object') return undefined; + current = current[part]; + } + return current; +} + +function normalizeString(value) { + return String(value || '').trim(); +} + +function normalizeKey(value) { + return normalizeString(value).toLowerCase(); +} + +function pickWorkflowId(filePath, definition) { + return normalizeString(definition.id) || path.basename(filePath).replace(/\.(ya?ml)$/i, ''); +} + +function findRunbook(repoDir, id, definition) { + const linkedRunbook = getNested(definition, ['links', 'runbook']); + const candidates = [ + linkedRunbook && path.resolve(repoDir, linkedRunbook), + path.join(repoDir, 'docs', 'workflows', 'runbooks', `${id}.md`), + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate.startsWith(repoDir) && fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function findLatestReport(repoDir, id) { + const reportsDir = path.join(repoDir, 'reports', 'workflows'); + const reports = listFiles(reportsDir, (filePath) => /\.(md|txt|json|ya?ml)$/i.test(filePath)) + .map((filePath) => { + let stat; + try { stat = fs.statSync(filePath); } catch { return null; } + const base = path.basename(filePath).toLowerCase(); + const score = base.includes(normalizeKey(id)) ? 2 : 0; + return { filePath, mtimeMs: stat.mtimeMs, score }; + }) + .filter(Boolean) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs); + return reports[0]?.filePath || null; +} + +function findCronJob(definition, id, cronJobs = []) { + const explicitIds = [ + getNested(definition, ['execution', 'hermes_cron_job_id']), + getNested(definition, ['execution', 'cron_job_id']), + definition.hermes_cron_job_id, + definition.cron_job_id, + ].map(normalizeKey).filter(Boolean); + + const names = [id, definition.name].map(normalizeKey).filter(Boolean); + + return cronJobs.find((job) => explicitIds.includes(normalizeKey(job.id))) + || cronJobs.find((job) => names.includes(normalizeKey(job.name)) || names.includes(normalizeKey(job.id))) + || cronJobs.find((job) => names.some((name) => normalizeKey(job.name).includes(name))) + || null; +} + +function classifyWorkflow({ runbookPath, cron }) { + const warnings = []; + const deliver = normalizeKey(cron?.deliver); + const lastDeliveryError = normalizeString(cron?.lastDeliveryError || cron?.deliveryError); + const lastStatus = normalizeKey(cron?.lastStatus); + const cronStatus = normalizeKey(cron?.status); + + if (lastDeliveryError) { + if (deliver === 'local' && /deliver=origin|origin/i.test(lastDeliveryError)) { + warnings.push('last_delivery_error appears stale because current delivery is local'); + } else { + return { status: 'active_failure', warnings: [lastDeliveryError] }; + } + } + + if (['failed', 'failure', 'error'].includes(lastStatus) || ['failed', 'failure', 'error'].includes(cronStatus)) { + return { status: 'active_failure', warnings: [`cron status indicates ${lastStatus || cronStatus}`] }; + } + + if (warnings.length) return { status: 'stale_warning', warnings }; + if (!runbookPath) return { status: 'missing_runbook', warnings: ['runbook is missing'] }; + if (!cron) return { status: 'missing_cron', warnings: ['cron job is not linked'] }; + return { status: 'ok', warnings }; +} + +function workflowSummary(workflows) { + const summary = { + total: workflows.length, + ok: 0, + active_failure: 0, + stale_warning: 0, + missing_runbook: 0, + missing_cron: 0, + unknown: 0, + }; + for (const workflow of workflows) { + const key = workflow.status || 'unknown'; + summary[key] = (summary[key] || 0) + 1; + } + return summary; +} + +function buildWorkflowIndex({ repoDir = resolveMiraRepoDir(), cronJobs = [] } = {}) { + const resolvedRepoDir = path.resolve(repoDir); + const definitionsDir = path.join(resolvedRepoDir, 'docs', 'workflows', 'definitions'); + const definitionFiles = listFiles(definitionsDir, (filePath) => /\.ya?ml$/i.test(filePath)); + + const workflows = definitionFiles.map((definitionPath) => { + let parsed; + try { + parsed = readYamlFile(definitionPath); + } catch (error) { + const id = path.basename(definitionPath).replace(/\.(ya?ml)$/i, ''); + return { + id, + name: id, + definitionPath: toPosixRelative(resolvedRepoDir, definitionPath), + runbookPath: null, + latestReportPath: null, + cron: null, + status: 'active_failure', + warnings: [`definition parse failed: ${error.message}`], + }; + } + + const definition = parsed.data; + const id = pickWorkflowId(definitionPath, definition); + const runbookPath = findRunbook(resolvedRepoDir, id, definition); + const latestReportPath = findLatestReport(resolvedRepoDir, id); + const cron = findCronJob(definition, id, cronJobs); + const health = classifyWorkflow({ runbookPath, cron }); + + return { + id, + name: normalizeString(definition.name) || id, + businessGoal: normalizeString(definition.business_goal || definition.businessGoal), + schedule: getNested(definition, ['schedule', 'human_readable']) + || getNested(definition, ['schedule', 'expression']) + || normalizeString(cron?.schedule), + riskLevel: getNested(definition, ['risk', 'level']) || definition.risk_level || 'unknown', + definitionPath: toPosixRelative(resolvedRepoDir, definitionPath), + runbookPath: runbookPath ? toPosixRelative(resolvedRepoDir, runbookPath) : null, + latestReportPath: latestReportPath ? toPosixRelative(resolvedRepoDir, latestReportPath) : null, + cron: cron ? { + id: cron.id, + name: cron.name, + status: cron.status, + schedule: cron.schedule, + nextRun: cron.nextRun, + lastRun: cron.lastRun, + lastStatus: cron.lastStatus, + deliver: cron.deliver, + lastDeliveryError: cron.lastDeliveryError, + } : null, + status: health.status, + warnings: health.warnings, + }; + }); + + workflows.sort((a, b) => a.id.localeCompare(b.id)); + return { + repoDir: resolvedRepoDir, + workflows, + summary: workflowSummary(workflows), + }; +} + +function getWorkflowDetail({ repoDir = resolveMiraRepoDir(), id, cronJobs = [] } = {}) { + const index = buildWorkflowIndex({ repoDir, cronJobs }); + const workflow = index.workflows.find((item) => item.id === id); + if (!workflow) return null; + const definitionAbs = path.resolve(index.repoDir, workflow.definitionPath); + const runbookAbs = workflow.runbookPath ? path.resolve(index.repoDir, workflow.runbookPath) : null; + const reportAbs = workflow.latestReportPath ? path.resolve(index.repoDir, workflow.latestReportPath) : null; + + return { + ...workflow, + repoDir: index.repoDir, + definitionRaw: fs.existsSync(definitionAbs) ? fs.readFileSync(definitionAbs, 'utf8') : '', + runbookRaw: runbookAbs && fs.existsSync(runbookAbs) ? fs.readFileSync(runbookAbs, 'utf8') : '', + latestReportRaw: reportAbs && fs.existsSync(reportAbs) ? fs.readFileSync(reportAbs, 'utf8').slice(0, 12000) : '', + }; +} + +module.exports = { + buildWorkflowIndex, + classifyWorkflow, + findCronJob, + getWorkflowDetail, + resolveMiraRepoDir, +}; diff --git a/server.js b/server.js index 78bf23f..0f6c21e 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,11 @@ const { mergeSessionsFromSources, parseHermesSessionsList, } = require('./lib/session-list'); +const { + buildWorkflowIndex, + getWorkflowDetail, + resolveMiraRepoDir, +} = require('./lib/workflows'); // ── TUI Gateway Bridge ── const { getBridge, killAllBridges } = require('./lib/tui-gateway-bridge'); @@ -1211,6 +1216,11 @@ function parseHermesCronList(raw) { continue; } if (!current) continue; + const deliveryFailed = trimmed.match(/Delivery failed:\s*(.*)$/i); + if (deliveryFailed) { + current.lastDeliveryError = deliveryFailed[1].trim(); + continue; + } const kv = trimmed.match(/^([A-Za-z ]+):\s*(.*)$/); if (!kv) continue; const key = kv[1].toLowerCase(); @@ -1219,8 +1229,14 @@ function parseHermesCronList(raw) { else if (key === 'schedule') current.schedule = value || 'n/a'; else if (key === 'repeat') current.repeat = value || null; else if (key === 'next run') current.nextRun = value || null; - else if (key === 'last run') current.lastRun = value || null; + else if (key === 'last run') { + current.lastRun = value || null; + const statusMatch = value.match(/\s+(ok|failed|failure|error|cancelled|timeout)\s*$/i); + if (statusMatch) current.lastStatus = statusMatch[1].toLowerCase(); + } else if (key === 'deliver') current.deliver = value || 'n/a'; + else if (key === 'last status') current.lastStatus = value || null; + else if (key === 'last delivery error') current.lastDeliveryError = value || null; } flush(); return jobs; @@ -2794,6 +2810,29 @@ app.post('/api/cron/:action', requireAuth, requireCsrf, requirePerm('cron.manage } }); +app.get('/api/workflows', requireAuth, async (req, res) => { + try { + const repoDir = resolveMiraRepoDir(req.query.repoDir || process.env.MIRA_REPO_DIR); + const cronJobsData = await getCronJobs(); + const result = buildWorkflowIndex({ repoDir, cronJobs: cronJobsData }); + return res.json({ ok: true, ...result }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message || 'failed to load workflows' }); + } +}); + +app.get('/api/workflows/:id', requireAuth, async (req, res) => { + try { + const repoDir = resolveMiraRepoDir(req.query.repoDir || process.env.MIRA_REPO_DIR); + const cronJobsData = await getCronJobs(); + const workflow = getWorkflowDetail({ repoDir, id: req.params.id, cronJobs: cronJobsData }); + if (!workflow) return res.status(404).json({ ok: false, error: 'workflow not found' }); + return res.json({ ok: true, workflow }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message || 'failed to load workflow' }); + } +}); + app.post('/internal/cron/:action', async (req, res) => { const secret = String(req.get('x-hermes-control-secret') || ''); if (!secret || !safeTimingEqual(secret, CONTROL_SECRET)) return res.status(403).json({ error: 'forbidden' }); diff --git a/src/css/components.css b/src/css/components.css index af400cd..0ee1b0b 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -163,6 +163,81 @@ gap: 16px; } +/* Workflows */ +.workflow-source { + margin: -8px 0 16px; + color: var(--fg-muted); + font-size: 12px; +} +.workflow-summary-grid { + grid-template-columns: repeat(4, minmax(120px, 1fr)); + margin-bottom: 18px; +} +.workflow-summary-card { + min-height: 72px; +} +.workflow-summary-value { + margin-top: 10px; + font-size: 28px; + font-weight: 700; +} +.workflow-table td { + vertical-align: top; +} +.workflow-name { + font-weight: 700; + color: var(--fg); +} +.workflow-muted, +.workflow-goal { + margin-top: 4px; + color: var(--fg-muted); + font-size: 12px; + line-height: 1.4; +} +.workflow-goal { + max-width: 360px; +} +.workflow-warning { + margin-top: 6px; + color: var(--amber); + font-size: 12px; + line-height: 1.4; + max-width: 260px; +} +.workflow-warning-block { + margin: 14px 0; + padding: 10px; + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: var(--radius); + background: rgba(245, 158, 11, 0.08); + color: var(--amber); + font-size: 12px; +} +.workflow-detail-modal { + width: min(980px, 94vw); + max-height: 90vh; + overflow: auto; +} +.workflow-detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 12px; + margin-top: 14px; + font-size: 13px; +} +.workflow-detail-body { + max-height: 46vh; + overflow: auto; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel); + color: var(--fg); + white-space: pre-wrap; + font-size: 12px; +} + /* Home image card */ .home-image-card { background: var(--bg-card); diff --git a/src/index.html b/src/index.html index 8639a4a..7dec3d8 100644 --- a/src/index.html +++ b/src/index.html @@ -54,6 +54,7 @@ Agents Usage Skills + Workflows Chat Logs Maintenance @@ -101,6 +102,7 @@
+
diff --git a/src/js/main.js b/src/js/main.js index 3586191..8a1ea02 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -272,6 +272,9 @@ async function loadPage(page, params = {}) { case 'skills': await loadSkills(container); break; + case 'workflows': + await loadWorkflows(container); + break; case 'maintenance': await loadMaintenance(container); break; @@ -5799,6 +5802,156 @@ window.markAllNotifRead = async function() { renderNotifications(); }; + +// ============================================ +// Workflows +// ============================================ +function workflowStatusClass(status) { + if (status === 'ok') return 'status-ok'; + if (status === 'stale_warning' || status === 'missing_runbook' || status === 'missing_cron') return 'status-warn'; + if (status === 'active_failure') return 'status-err'; + return 'status-off'; +} + +function formatWorkflowStatus(status) { + return String(status || 'unknown').replace(/_/g, ' '); +} + +function renderWorkflowSummary(summary = {}) { + const items = [ + ['Total', summary.total || 0, 'status-off'], + ['OK', summary.ok || 0, 'status-ok'], + ['Warnings', (summary.stale_warning || 0) + (summary.missing_runbook || 0) + (summary.missing_cron || 0), 'status-warn'], + ['Failures', summary.active_failure || 0, 'status-err'], + ]; + return items.map(([label, value, cls]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `).join(''); +} + +function renderWorkflowRows(workflows = []) { + if (!workflows.length) { + return '
No workflow definitions found in docs/workflows/definitions.
'; + } + return ` +
+ + + + + + + + + + + + + ${workflows.map((workflow) => ` + + + + + + + + + `).join('')} + +
WorkflowStatusScheduleCronArtifacts
+
${escapeHtml(workflow.name || workflow.id)}
+
${escapeHtml(workflow.id)}
+ ${workflow.businessGoal ? `
${escapeHtml(workflow.businessGoal)}
` : ''} +
+ ${escapeHtml(formatWorkflowStatus(workflow.status))} + ${(workflow.warnings || []).slice(0, 2).map((warning) => `
${escapeHtml(warning)}
`).join('')} +
${escapeHtml(workflow.schedule || 'n/a')} + ${workflow.cron ? ` +
${escapeHtml(workflow.cron.name || workflow.cron.id)}
+
${escapeHtml(workflow.cron.id || '')} · ${escapeHtml(workflow.cron.status || 'unknown')}
+
deliver: ${escapeHtml(workflow.cron.deliver || 'n/a')}
+ ` : 'Not linked'} +
+
definition: ${escapeHtml(workflow.definitionPath || 'n/a')}
+
runbook: ${escapeHtml(workflow.runbookPath || 'missing')}
+ ${workflow.latestReportPath ? `
report: ${escapeHtml(workflow.latestReportPath)}
` : ''} +
+
+ `; +} + +async function loadWorkflows(container) { + const res = await api('/api/workflows'); + if (!res.ok) { + container.innerHTML = `
Failed to load workflows: ${escapeHtml(res.error || 'unknown error')}
`; + return; + } + + container.innerHTML = ` + +
Source: ${escapeHtml(res.repoDir || '')}
+
${renderWorkflowSummary(res.summary)}
+ ${renderWorkflowRows(res.workflows || [])} + `; +} + +window.showWorkflowDetail = async function(id) { + const res = await api(`/api/workflows/${encodeURIComponent(id)}`); + if (!res.ok) { + showToast(`Failed to load workflow: ${res.error || 'unknown error'}`, 'error'); + return; + } + const workflow = res.workflow || {}; + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + const body = overlay.querySelector('#workflow-detail-body'); + const payloads = { + definition: workflow.definitionRaw || '', + runbook: workflow.runbookRaw || 'Runbook is missing.', + report: workflow.latestReportRaw || 'No report found.', + }; + overlay.querySelectorAll('.workflow-detail-tabs .tab').forEach((tab) => { + tab.addEventListener('click', () => { + overlay.querySelectorAll('.workflow-detail-tabs .tab').forEach((t) => t.classList.remove('active')); + tab.classList.add('active'); + body.textContent = payloads[tab.dataset.target] || ''; + }); + }); +}; + // ============================================ // API Helper // ============================================ diff --git a/test/workflows.test.js b/test/workflows.test.js new file mode 100644 index 0000000..abad84a --- /dev/null +++ b/test/workflows.test.js @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + buildWorkflowIndex, + getWorkflowDetail, +} = require('../lib/workflows'); + +function makeRepo() { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hci-workflows-')); + fs.mkdirSync(path.join(repoDir, 'docs', 'workflows', 'definitions'), { recursive: true }); + fs.mkdirSync(path.join(repoDir, 'docs', 'workflows', 'runbooks'), { recursive: true }); + fs.mkdirSync(path.join(repoDir, 'reports', 'workflows'), { recursive: true }); + return repoDir; +} + +test('buildWorkflowIndex reads MiraRepo workflow definitions and links cron/runbook/report', () => { + const repoDir = makeRepo(); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'definitions', 'weekly-memory-refactor.yaml'), `id: weekly-memory-refactor +name: Weekly Memory Refactor +business_goal: Keep memory concise +schedule: + human_readable: Weekly Monday 06:30 JST +risk: + level: low +execution: + hermes_cron_job_id: d27468bad831 +links: + runbook: docs/workflows/runbooks/weekly-memory-refactor.md +`); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'runbooks', 'weekly-memory-refactor.md'), '# Runbook\n'); + fs.writeFileSync(path.join(repoDir, 'reports', 'workflows', 'weekly-memory-refactor-report.md'), '# Report\n'); + + const index = buildWorkflowIndex({ + repoDir, + cronJobs: [{ + id: 'd27468bad831', + name: 'weekly-memory-refactor', + status: 'active', + schedule: '0 6 * * 1', + deliver: 'local', + }], + }); + + assert.equal(index.summary.total, 1); + assert.equal(index.summary.ok, 1); + assert.equal(index.workflows[0].id, 'weekly-memory-refactor'); + assert.equal(index.workflows[0].cron.id, 'd27468bad831'); + assert.equal(index.workflows[0].runbookPath, 'docs/workflows/runbooks/weekly-memory-refactor.md'); + assert.equal(index.workflows[0].latestReportPath, 'reports/workflows/weekly-memory-refactor-report.md'); +}); + +test('getWorkflowDetail exposes raw definition and runbook without mutating files', () => { + const repoDir = makeRepo(); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'definitions', 'sample.yaml'), 'id: sample\nname: Sample\n'); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'runbooks', 'sample.md'), '# Sample Runbook\n'); + + const detail = getWorkflowDetail({ repoDir, id: 'sample', cronJobs: [] }); + + assert.equal(detail.id, 'sample'); + assert.match(detail.definitionRaw, /id: sample/); + assert.match(detail.runbookRaw, /Sample Runbook/); + assert.equal(detail.status, 'missing_cron'); +}); + +test('local delivery with an origin delivery error is classified as stale warning', () => { + const repoDir = makeRepo(); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'definitions', 'delivery.yaml'), 'id: delivery\nname: Delivery\n'); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'runbooks', 'delivery.md'), '# Delivery\n'); + + const index = buildWorkflowIndex({ + repoDir, + cronJobs: [{ + id: 'delivery-job', + name: 'delivery', + status: 'active', + deliver: 'local', + lastDeliveryError: 'deliver=origin failed for old thread', + }], + }); + + assert.equal(index.workflows[0].status, 'stale_warning'); +}); From e409b8d0eddbb96ef234b79d1401edead652d6fd Mon Sep 17 00:00:00 2001 From: mira-zennai Date: Fri, 8 May 2026 07:55:17 +0900 Subject: [PATCH 2/6] fix: make workflow cron lookup launchagent-safe --- server.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 0f6c21e..eb6bd81 100644 --- a/server.js +++ b/server.js @@ -99,9 +99,14 @@ function shell(cmd, timeout = '8s') { } // Safer execution — no bash interpretation, direct args +const HERMES_CLI = process.env.HERMES_CLI + || path.join(os.homedir(), '.hermes', 'hermes-agent', 'venv', 'bin', 'hermes') + || 'hermes'; + function execHermes(args, timeout = 30000) { + const command = fs.existsSync(HERMES_CLI) ? HERMES_CLI : 'hermes'; return new Promise((resolve) => { - execFile('hermes', args, { + execFile(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024, timeout, @@ -1245,7 +1250,7 @@ function parseHermesCronList(raw) { async function getCronJobs() { const now = Date.now(); if (getCronJobs.cache && now - getCronJobs.cache.at < 10_000) return getCronJobs.cache.data; - const raw = await shell('hermes cron list'); + const raw = await execHermes(['cron', 'list', '--all'], 15000); if (raw) { const data = parseHermesCronList(raw); getCronJobs.cache = { at: now, data }; From 52ab516906f0784ddce9c0d6add88107b836bae2 Mon Sep 17 00:00:00 2001 From: mira-zennai Date: Sun, 31 May 2026 13:24:38 +0900 Subject: [PATCH 3/6] feat: surface workflow worker statuses --- lib/workflows.js | 55 ++++++++++++++++++++++++++++++++++++++++++ src/css/components.css | 11 ++++++++- src/js/main.js | 38 ++++++++++++++++++++++++++++- test/workflows.test.js | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/lib/workflows.js b/lib/workflows.js index 3a9addd..9388ef0 100644 --- a/lib/workflows.js +++ b/lib/workflows.js @@ -134,6 +134,54 @@ function classifyWorkflow({ runbookPath, cron }) { return { status: 'ok', warnings }; } +const WORKER_STATUS_KEYS = ['starting', 'generating', 'waiting_approval', 'idle', 'error', 'stopped', 'unknown']; + +function normalizeWorkerStatus(status) { + const value = normalizeKey(status).replace(/[\s-]+/g, '_'); + if (['starting', 'booting', 'queued'].includes(value)) return 'starting'; + if (['generating', 'running', 'busy', 'in_progress', 'working'].includes(value)) return 'generating'; + if (['waiting_approval', 'approval_required', 'blocked', 'paused_for_approval'].includes(value)) return 'waiting_approval'; + if (['idle', 'ready', 'complete', 'completed', 'done', 'success', 'ok'].includes(value)) return 'idle'; + if (['error', 'failed', 'failure', 'crashed', 'timeout'].includes(value)) return 'error'; + if (['stopped', 'cancelled', 'canceled', 'killed', 'terminated'].includes(value)) return 'stopped'; + return 'unknown'; +} + +function normalizeWorkers(definition) { + const rawWorkers = getNested(definition, ['execution', 'workers']) || definition.workers || []; + if (!Array.isArray(rawWorkers)) return []; + return rawWorkers + .filter((worker) => worker && typeof worker === 'object') + .map((worker, index) => ({ + id: normalizeString(worker.id || worker.name || `worker-${index + 1}`), + label: normalizeString(worker.label || worker.title || worker.name || worker.id || `Worker ${index + 1}`), + provider: normalizeString(worker.provider || worker.type || worker.agent || 'unknown'), + status: normalizeWorkerStatus(worker.status || worker.state), + sessionId: normalizeString(worker.session_id || worker.sessionId || worker.providerSessionId), + updatedAt: normalizeString(worker.updated_at || worker.updatedAt || worker.last_seen_at || worker.lastSeenAt), + note: normalizeString(worker.note || worker.message || worker.reason), + })); +} + +function emptyWorkerSummary() { + return WORKER_STATUS_KEYS.reduce((summary, status) => { + summary[status] = 0; + return summary; + }, { total: 0 }); +} + +function summarizeWorkers(workflows) { + const summary = emptyWorkerSummary(); + for (const workflow of workflows) { + for (const worker of workflow.workers || []) { + summary.total += 1; + const status = WORKER_STATUS_KEYS.includes(worker.status) ? worker.status : 'unknown'; + summary[status] += 1; + } + } + return summary; +} + function workflowSummary(workflows) { const summary = { total: workflows.length, @@ -143,11 +191,13 @@ function workflowSummary(workflows) { missing_runbook: 0, missing_cron: 0, unknown: 0, + workers: emptyWorkerSummary(), }; for (const workflow of workflows) { const key = workflow.status || 'unknown'; summary[key] = (summary[key] || 0) + 1; } + summary.workers = summarizeWorkers(workflows); return summary; } @@ -180,6 +230,7 @@ function buildWorkflowIndex({ repoDir = resolveMiraRepoDir(), cronJobs = [] } = const latestReportPath = findLatestReport(resolvedRepoDir, id); const cron = findCronJob(definition, id, cronJobs); const health = classifyWorkflow({ runbookPath, cron }); + const workers = normalizeWorkers(definition); return { id, @@ -203,6 +254,7 @@ function buildWorkflowIndex({ repoDir = resolveMiraRepoDir(), cronJobs = [] } = deliver: cron.deliver, lastDeliveryError: cron.lastDeliveryError, } : null, + workers, status: health.status, warnings: health.warnings, }; @@ -238,5 +290,8 @@ module.exports = { classifyWorkflow, findCronJob, getWorkflowDetail, + normalizeWorkerStatus, + normalizeWorkers, resolveMiraRepoDir, + summarizeWorkers, }; diff --git a/src/css/components.css b/src/css/components.css index 163b390..25810be 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -170,7 +170,7 @@ font-size: 12px; } .workflow-summary-grid { - grid-template-columns: repeat(4, minmax(120px, 1fr)); + grid-template-columns: repeat(5, minmax(120px, 1fr)); margin-bottom: 18px; } .workflow-summary-card { @@ -198,6 +198,15 @@ .workflow-goal { max-width: 360px; } +.workflow-worker-row { + display: grid; + gap: 3px; + margin-bottom: 8px; + min-width: 150px; +} +.workflow-worker-row .badge { + width: fit-content; +} .workflow-warning { margin-top: 6px; color: var(--amber); diff --git a/src/js/main.js b/src/js/main.js index 269a46c..8528502 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -5988,10 +5988,32 @@ function workflowStatusClass(status) { return 'status-off'; } +function workerStatusClass(status) { + if (status === 'idle') return 'status-ok'; + if (status === 'starting' || status === 'generating') return 'status-warn'; + if (status === 'waiting_approval') return 'status-err'; + if (status === 'error') return 'status-err'; + if (status === 'stopped') return 'status-off'; + return 'status-off'; +} + function formatWorkflowStatus(status) { return String(status || 'unknown').replace(/_/g, ' '); } +function renderWorkerSummary(summary = {}) { + const workers = summary.workers || {}; + const active = (workers.starting || 0) + (workers.generating || 0); + const attention = (workers.waiting_approval || 0) + (workers.error || 0); + return ` +
+
Workers
+
${escapeHtml(workers.total || 0)}
+
active: ${escapeHtml(active)} · attention: ${escapeHtml(attention)}
+
+ `; +} + function renderWorkflowSummary(summary = {}) { const items = [ ['Total', summary.total || 0, 'status-off'], @@ -6004,7 +6026,18 @@ function renderWorkflowSummary(summary = {}) {
${escapeHtml(label)}
${escapeHtml(value)}
- `).join(''); + `).join('') + renderWorkerSummary(summary); +} + +function renderWorkflowWorkers(workers = []) { + if (!workers.length) return 'No workers'; + return workers.slice(0, 3).map((worker) => ` +
+ ${escapeHtml(formatWorkflowStatus(worker.status))} + ${escapeHtml(worker.label || worker.id)} +
${escapeHtml(worker.provider || 'unknown')}${worker.sessionId ? ` · ${escapeHtml(worker.sessionId)}` : ''}
+
+ `).join('') + (workers.length > 3 ? `
+${escapeHtml(workers.length - 3)} more
` : ''); } function renderWorkflowRows(workflows = []) { @@ -6019,6 +6052,7 @@ function renderWorkflowRows(workflows = []) { Workflow Status Schedule + Workers Cron Artifacts @@ -6037,6 +6071,7 @@ function renderWorkflowRows(workflows = []) { ${(workflow.warnings || []).slice(0, 2).map((warning) => `
${escapeHtml(warning)}
`).join('')} ${escapeHtml(workflow.schedule || 'n/a')} + ${renderWorkflowWorkers(workflow.workers || [])} ${workflow.cron ? `
${escapeHtml(workflow.cron.name || workflow.cron.id)}
@@ -6097,6 +6132,7 @@ window.showWorkflowDetail = async function(id) {
Status
${escapeHtml(formatWorkflowStatus(workflow.status))}
Schedule
${escapeHtml(workflow.schedule || 'n/a')}
Risk
${escapeHtml(workflow.riskLevel || 'unknown')}
+
Workers
${renderWorkflowWorkers(workflow.workers || [])}
Cron
${workflow.cron ? `${escapeHtml(workflow.cron.name || workflow.cron.id)}
${escapeHtml(workflow.cron.id || '')}` : 'Not linked'}
${(workflow.warnings || []).length ? `
${(workflow.warnings || []).map((w) => `
⚠ ${escapeHtml(w)}
`).join('')}
` : ''} diff --git a/test/workflows.test.js b/test/workflows.test.js index abad84a..4399beb 100644 --- a/test/workflows.test.js +++ b/test/workflows.test.js @@ -7,6 +7,7 @@ const test = require('node:test'); const { buildWorkflowIndex, getWorkflowDetail, + normalizeWorkerStatus, } = require('../lib/workflows'); function makeRepo() { @@ -84,3 +85,51 @@ test('local delivery with an origin delivery error is classified as stale warnin assert.equal(index.workflows[0].status, 'stale_warning'); }); + +test('normalizeWorkerStatus maps ADHDev-style worker states to the HCI status vocabulary', () => { + assert.equal(normalizeWorkerStatus('waiting_approval'), 'waiting_approval'); + assert.equal(normalizeWorkerStatus('generating'), 'generating'); + assert.equal(normalizeWorkerStatus('running'), 'generating'); + assert.equal(normalizeWorkerStatus('complete'), 'idle'); + assert.equal(normalizeWorkerStatus('blocked'), 'waiting_approval'); + assert.equal(normalizeWorkerStatus('crashed'), 'error'); + assert.equal(normalizeWorkerStatus(''), 'unknown'); +}); + +test('buildWorkflowIndex exposes normalized worker session status and summary counts', () => { + const repoDir = makeRepo(); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'definitions', 'workers.yaml'), `id: workers +name: Worker Status +execution: + workers: + - id: codex-lane + label: Codex Lane + provider: codex + status: running + session_id: codex-session-1 + updated_at: 2026-05-31T13:20:00+09:00 + - id: claude-review + provider: claude + status: waiting_approval +`); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'runbooks', 'workers.md'), '# Workers\n'); + + const index = buildWorkflowIndex({ repoDir, cronJobs: [{ id: 'workers', name: 'workers', status: 'active' }] }); + + assert.deepEqual(index.summary.workers, { + total: 2, + starting: 0, + generating: 1, + waiting_approval: 1, + idle: 0, + error: 0, + stopped: 0, + unknown: 0, + }); + assert.equal(index.workflows[0].workers[0].id, 'codex-lane'); + assert.equal(index.workflows[0].workers[0].label, 'Codex Lane'); + assert.equal(index.workflows[0].workers[0].provider, 'codex'); + assert.equal(index.workflows[0].workers[0].status, 'generating'); + assert.equal(index.workflows[0].workers[0].sessionId, 'codex-session-1'); + assert.equal(index.workflows[0].workers[1].status, 'waiting_approval'); +}); From a68b4f9b3e592f1ae4feadec5f02cf1e802081f5 Mon Sep 17 00:00:00 2001 From: mira-zennai Date: Sun, 31 May 2026 13:47:58 +0900 Subject: [PATCH 4/6] feat: overlay runtime workflow worker snapshots --- lib/workflows.js | 82 +++++++++++++++++++++++++++++++++++------- test/workflows.test.js | 47 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/lib/workflows.js b/lib/workflows.js index 9388ef0..4c546f3 100644 --- a/lib/workflows.js +++ b/lib/workflows.js @@ -147,20 +147,78 @@ function normalizeWorkerStatus(status) { return 'unknown'; } -function normalizeWorkers(definition) { - const rawWorkers = getNested(definition, ['execution', 'workers']) || definition.workers || []; +function normalizeWorker(worker, index = 0, { fallback = true } = {}) { + const id = normalizeString(worker.id || worker.name || (fallback ? `worker-${index + 1}` : '')); + return { + id, + label: normalizeString(worker.label || worker.title || worker.name || (fallback ? worker.id || `Worker ${index + 1}` : '')), + provider: normalizeString(worker.provider || worker.type || worker.agent || (fallback ? 'unknown' : '')), + status: normalizeWorkerStatus(worker.status || worker.state), + sessionId: normalizeString(worker.session_id || worker.sessionId || worker.providerSessionId), + updatedAt: normalizeString(worker.updated_at || worker.updatedAt || worker.last_seen_at || worker.lastSeenAt), + note: normalizeString(worker.note || worker.message || worker.reason), + }; +} + +function normalizeWorkerList(rawWorkers, options = {}) { if (!Array.isArray(rawWorkers)) return []; return rawWorkers .filter((worker) => worker && typeof worker === 'object') - .map((worker, index) => ({ - id: normalizeString(worker.id || worker.name || `worker-${index + 1}`), - label: normalizeString(worker.label || worker.title || worker.name || worker.id || `Worker ${index + 1}`), - provider: normalizeString(worker.provider || worker.type || worker.agent || 'unknown'), - status: normalizeWorkerStatus(worker.status || worker.state), - sessionId: normalizeString(worker.session_id || worker.sessionId || worker.providerSessionId), - updatedAt: normalizeString(worker.updated_at || worker.updatedAt || worker.last_seen_at || worker.lastSeenAt), - note: normalizeString(worker.note || worker.message || worker.reason), - })); + .map((worker, index) => normalizeWorker(worker, index, options)); +} + +function resolveRepoLocalPath(repoDir, filePath) { + const rawPath = normalizeString(filePath); + if (!rawPath || path.isAbsolute(rawPath)) return null; + const resolvedRepoDir = path.resolve(repoDir); + const resolvedPath = path.resolve(resolvedRepoDir, rawPath); + const relative = path.relative(resolvedRepoDir, resolvedPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) return null; + return resolvedPath; +} + +function readWorkerStatusSnapshot(repoDir, definition) { + const statusFile = getNested(definition, ['execution', 'worker_status_file']) + || getNested(definition, ['execution', 'workerStatusFile']) + || definition.worker_status_file + || definition.workerStatusFile; + const snapshotPath = resolveRepoLocalPath(repoDir, statusFile); + if (!snapshotPath || !fs.existsSync(snapshotPath)) return []; + + try { + const snapshot = readYamlFile(snapshotPath).data; + if (Array.isArray(snapshot)) return snapshot; + if (Array.isArray(snapshot?.workers)) return snapshot.workers; + return []; + } catch { + return []; + } +} + +function mergeWorkerSnapshots(baseWorkers, runtimeWorkers) { + const merged = new Map(); + for (const worker of baseWorkers) merged.set(worker.id, worker); + for (const runtimeWorker of runtimeWorkers) { + if (!runtimeWorker.id) continue; + const previous = merged.get(runtimeWorker.id) || {}; + merged.set(runtimeWorker.id, { + ...previous, + ...runtimeWorker, + label: runtimeWorker.label || previous.label || runtimeWorker.id, + provider: runtimeWorker.provider || previous.provider || 'unknown', + }); + } + return Array.from(merged.values()); +} + +function normalizeWorkers(definition, { repoDir } = {}) { + const rawWorkers = getNested(definition, ['execution', 'workers']) || definition.workers || []; + const baseWorkers = normalizeWorkerList(rawWorkers); + if (!repoDir) return baseWorkers; + + const runtimeWorkers = normalizeWorkerList(readWorkerStatusSnapshot(repoDir, definition), { fallback: false }); + if (!runtimeWorkers.length) return baseWorkers; + return mergeWorkerSnapshots(baseWorkers, runtimeWorkers); } function emptyWorkerSummary() { @@ -230,7 +288,7 @@ function buildWorkflowIndex({ repoDir = resolveMiraRepoDir(), cronJobs = [] } = const latestReportPath = findLatestReport(resolvedRepoDir, id); const cron = findCronJob(definition, id, cronJobs); const health = classifyWorkflow({ runbookPath, cron }); - const workers = normalizeWorkers(definition); + const workers = normalizeWorkers(definition, { repoDir: resolvedRepoDir }); return { id, diff --git a/test/workflows.test.js b/test/workflows.test.js index 4399beb..8f6a8ed 100644 --- a/test/workflows.test.js +++ b/test/workflows.test.js @@ -133,3 +133,50 @@ execution: assert.equal(index.workflows[0].workers[0].sessionId, 'codex-session-1'); assert.equal(index.workflows[0].workers[1].status, 'waiting_approval'); }); + +test('buildWorkflowIndex overlays runtime worker status snapshots from repo-local files', () => { + const repoDir = makeRepo(); + fs.mkdirSync(path.join(repoDir, 'reports', 'workflows', 'runtime'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'definitions', 'runtime-workers.yaml'), `id: runtime-workers +name: Runtime Workers +execution: + worker_status_file: reports/workflows/runtime/runtime-workers.json + workers: + - id: codex-lane + label: Codex Lane + provider: codex + status: idle + session_id: old-session +`); + fs.writeFileSync(path.join(repoDir, 'docs', 'workflows', 'runbooks', 'runtime-workers.md'), '# Runtime Workers\n'); + fs.writeFileSync(path.join(repoDir, 'reports', 'workflows', 'runtime', 'runtime-workers.json'), JSON.stringify({ + workers: [{ + id: 'codex-lane', + status: 'blocked', + session_id: 'live-session-42', + updated_at: '2026-05-31T13:45:00+09:00', + note: 'approval required', + }, { + id: 'claude-review', + label: 'Claude Review', + provider: 'claude', + status: 'running', + }], + })); + + const index = buildWorkflowIndex({ repoDir, cronJobs: [{ id: 'runtime-workers', name: 'runtime-workers', status: 'active' }] }); + const workflow = index.workflows[0]; + + assert.equal(workflow.workers.length, 2); + assert.equal(workflow.workers[0].id, 'codex-lane'); + assert.equal(workflow.workers[0].label, 'Codex Lane'); + assert.equal(workflow.workers[0].provider, 'codex'); + assert.equal(workflow.workers[0].status, 'waiting_approval'); + assert.equal(workflow.workers[0].sessionId, 'live-session-42'); + assert.equal(workflow.workers[0].note, 'approval required'); + assert.equal(workflow.workers[1].id, 'claude-review'); + assert.equal(workflow.workers[1].status, 'generating'); + assert.equal(index.summary.workers.total, 2); + assert.equal(index.summary.workers.waiting_approval, 1); + assert.equal(index.summary.workers.generating, 1); +}); From 2c13f330a356926025f3fd5c95f293285313a547 Mon Sep 17 00:00:00 2001 From: mira-zennai Date: Sun, 31 May 2026 13:55:51 +0900 Subject: [PATCH 5/6] feat: add workflow worker status writer --- lib/workflow-worker-status.js | 93 +++++++++++++++++++++++++++++ package.json | 1 + scripts/workflow-worker-status.js | 85 ++++++++++++++++++++++++++ test/workflow-worker-status.test.js | 80 +++++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 lib/workflow-worker-status.js create mode 100644 scripts/workflow-worker-status.js create mode 100644 test/workflow-worker-status.test.js diff --git a/lib/workflow-worker-status.js b/lib/workflow-worker-status.js new file mode 100644 index 0000000..44a49a1 --- /dev/null +++ b/lib/workflow-worker-status.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +const path = require('path'); +const { normalizeWorkerStatus } = require('./workflows'); + +function normalizeString(value) { + return String(value || '').trim(); +} + +function resolveWorkerStatusPath(repoDir, file) { + const resolvedRepoDir = path.resolve(repoDir || process.cwd()); + const rawFile = normalizeString(file); + if (!rawFile || path.isAbsolute(rawFile)) { + throw new Error('worker status file must be a repo-local relative path'); + } + const resolvedPath = path.resolve(resolvedRepoDir, rawFile); + const relative = path.relative(resolvedRepoDir, resolvedPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('worker status file resolves outside repo'); + } + return resolvedPath; +} + +function readSnapshot(snapshotPath) { + try { + const raw = fs.readFileSync(snapshotPath, 'utf8'); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return { workers: parsed }; + if (parsed && typeof parsed === 'object') { + return { ...parsed, workers: Array.isArray(parsed.workers) ? parsed.workers : [] }; + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + return { workers: [] }; +} + +function buildWorkerPatch({ id, label, provider, status, sessionId, updatedAt, now, note }) { + const workerId = normalizeString(id); + if (!workerId) throw new Error('worker id is required'); + + const patch = { + id: workerId, + status: normalizeWorkerStatus(status), + updated_at: normalizeString(updatedAt || now || new Date().toISOString()), + }; + const normalizedLabel = normalizeString(label); + const normalizedProvider = normalizeString(provider); + const normalizedSessionId = normalizeString(sessionId); + const normalizedNote = normalizeString(note); + if (normalizedLabel) patch.label = normalizedLabel; + if (normalizedProvider) patch.provider = normalizedProvider; + if (normalizedSessionId) patch.session_id = normalizedSessionId; + if (normalizedNote) patch.note = normalizedNote; + return patch; +} + +function writeSnapshotAtomic(snapshotPath, snapshot) { + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + const tmpPath = `${snapshotPath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpPath, `${JSON.stringify(snapshot, null, 2)}\n`); + fs.renameSync(tmpPath, snapshotPath); +} + +function upsertWorkerStatus(options) { + const snapshotPath = resolveWorkerStatusPath(options.repoDir, options.file); + const snapshot = readSnapshot(snapshotPath); + const patch = buildWorkerPatch(options); + const workers = new Map(); + + for (const worker of snapshot.workers) { + if (worker && typeof worker === 'object' && normalizeString(worker.id)) { + workers.set(normalizeString(worker.id), { ...worker }); + } + } + + workers.set(patch.id, { + ...(workers.get(patch.id) || {}), + ...patch, + }); + + const nextSnapshot = { + ...snapshot, + updated_at: patch.updated_at, + workers: Array.from(workers.values()).sort((a, b) => String(a.id).localeCompare(String(b.id))), + }; + writeSnapshotAtomic(snapshotPath, nextSnapshot); + return nextSnapshot; +} + +module.exports = { + resolveWorkerStatusPath, + upsertWorkerStatus, +}; diff --git a/package.json b/package.json index 32d454d..92dfcbc 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "serve": "node server.js", "setup": "bash install.sh", "test": "node --test", + "worker-status": "node scripts/workflow-worker-status.js", "reset-password": "node scripts/reset-password.js" }, "dependencies": { diff --git a/scripts/workflow-worker-status.js b/scripts/workflow-worker-status.js new file mode 100644 index 0000000..83683b9 --- /dev/null +++ b/scripts/workflow-worker-status.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +const { upsertWorkerStatus } = require('../lib/workflow-worker-status'); + +function usage() { + return `Usage: node scripts/workflow-worker-status.js --repo --file --id --status [options] + +Options: + --label