diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/proposal.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/proposal.md new file mode 100644 index 0000000..0b52ae5 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/proposal.md @@ -0,0 +1,50 @@ +# dmux-style cockpit — Phase 4: logs viewer + +## Why + +Phase 1 wired the `[l]ogs` hotkey, phases 2-3 advertised it on the +welcome screen and shipped a similar overlay shape for projects. Phase +4 turns the placeholder logs panel into a real log viewer with the +same `[1] All [2] Info [3] Warnings [4] Errors [5] By Pane` filter row +the dmux UI uses. + +## What changes + +- New `src/cockpit/logs-reader.js`: + - `readLogs({ repoRoot, fs, sources, limit, tailBytes })` — walks + `apps/logs`, `.omc/logs`, `.omx/logs` (override via `sources`), + tails each `.log` file (default 32 KiB), splits into lines, + classifies each line with `classifyLevel`, returns + `{ entries, sources, counts }`. + - `classifyLevel(line)` — heuristic matcher for `error`, `warning`, + `debug`, default `info`. + - `filterEntries(entries, filter)` — slices by level or groups by + source for `by-pane`. + - `tallyLevels(entries)` — count summary. +- Real `renderLogsPanel`: + - Heading, summary row (`N total · N info · N warn · N err`), + `filter:` line, `sources:` count, the dmux filter row, then up to + 20 most-recent entries tagged `[INF]`/`[WRN]`/`[ERR]`/`[DBG]` with + source path and message. + - Footer hints: `r: rescan Esc: back to main`. +- Control state hooks: + - Pressing `l` populates `state.logs` / `state.logsCounts` / + `state.logsSources` / `state.logsFilter` lazily on first entry. + - `1` / `2` / `3` / `4` / `5` swap the active filter. + - `r` rescans (re-reads log tails). + - `Esc` returns to main (existing behavior). + +## Impact + +- New module is filesystem-injectable for unit tests (no real disk + I/O required in CI). +- ASCII-only renderer; no unicode glyphs. +- No safety-model change: branches, worktrees, locks, PR-only finish + flow are untouched. + +## Out of scope (later phases) + +- Phase 5: New-agent prompt overlay. +- Phase 6: Terminal pane action wiring. +- Future: live tail (currently re-reads on `r`), scroll buffer beyond + the last 20 entries, color-coded levels, copy-to-clipboard. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/specs/cockpit-logs/spec.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/specs/cockpit-logs/spec.md new file mode 100644 index 0000000..37502ce --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/specs/cockpit-logs/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: Cockpit ships a logs reader module +The cockpit SHALL expose a `logs-reader` module that tails `.log` +files under configurable directories, classifies each line by level, +and returns a stable result with per-level counts. + +#### Scenario: readLogs tails .log files and tallies levels +- **WHEN** `readLogs({ repoRoot, fs })` is called against a tree + containing `apps/logs/server.log` with mixed-severity lines and a + sibling `README.md` +- **THEN** the returned `entries` exclude the README and include one + classified entry per non-empty log line +- **AND** the returned `counts` accurately reflect the number of + `info`, `warning`, `error`, and `debug` entries. + +#### Scenario: classifyLevel maps common keywords +- **WHEN** `classifyLevel(line)` is called with lines containing + `error`, `Exception`, `warning`, or `debug` +- **THEN** the returned levels are `error`, `error`, `warning`, and + `debug` respectively +- **AND** any line without a matching keyword classifies as `info`. + +#### Scenario: filterEntries supports level and by-pane grouping +- **WHEN** `filterEntries(entries, 'error')` is called against a + mixed list +- **THEN** only entries with `level === 'error'` are returned. +- **AND** `filterEntries(entries, 'by-pane')` returns the entries + grouped by `source`, preserving relative order within each group. + +### Requirement: Logs panel renders the dmux filter row and tagged entries +The cockpit `logs` mode panel SHALL render a summary line with total +and per-level counts, the dmux-style `[1] All [2] Info [3] Warnings +[4] Errors [5] By Pane` filter row, the active filter label, the +source count, and up to 20 most-recent entries tagged with `[INF]`, +`[WRN]`, `[ERR]`, or `[DBG]`. The footer SHALL list `r: rescan` and +`Esc: back to main`. + +#### Scenario: Logs panel shows summary, filter row, tagged entries +- **WHEN** the cockpit is in `logs` mode with a known + `state.logs`, `state.logsCounts`, `state.logsSources`, and + `state.logsFilter === 'all'` +- **THEN** the rendered panel contains the substring `[1] All [2] + Info [3] Warnings [4] Errors [5] By Pane` +- **AND** every line from `state.logs` that ends up in the rendered + output is prefixed with `[INF]`, `[WRN]`, `[ERR]`, or `[DBG]` +- **AND** the footer contains `r: rescan`. + +### Requirement: Logs mode key handlers swap filters and rescan +The cockpit key handler SHALL respond to `1` / `2` / `3` / `4` / `5` +in `logs` mode by setting `state.logsFilter` to `all` / `info` / +`warning` / `error` / `by-pane` respectively. It SHALL respond to `r` +by re-reading the log sources and refreshing the cached entries. + +#### Scenario: 1-5 keys swap the active filter +- **WHEN** the cockpit is in `logs` mode with `logsFilter === 'all'` + and the user presses `2`, then `3`, then `4`, then `5`, then `1` +- **THEN** `logsFilter` becomes `info`, then `warning`, then `error`, + then `by-pane`, then `all`. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/tasks.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/tasks.md new file mode 100644 index 0000000..d44cc32 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase4-logs-2026-05-05-09-29/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## 1. Spec +- [x] 1.1 Capture proposal in `proposal.md` +- [x] 1.2 Capture spec delta in `specs/cockpit-logs/spec.md` + +## 2. Tests +- [x] 2.1 Add `test/cockpit-logs.test.js` covering `classifyLevel`, + `readLogs`, `filterEntries`, `tallyLevels`, the 1-5 filter + hotkeys, and the rendered logs panel. +- [x] 2.2 Verify existing cockpit-projects, cockpit-control, and + cockpit-sidebar tests still pass. + +## 3. Implementation +- [x] 3.1 Add `src/cockpit/logs-reader.js` with `readLogs`, + `classifyLevel`, `filterEntries`, `tallyLevels`, `tailFile`, + `listLogPaths`, and the `LEVELS` / `DEFAULT_*` constants. +- [x] 3.2 Replace placeholder `renderLogsPanel` in + `src/cockpit/control.js` with a real viewer (summary, filter + row, tagged entries, footer hints, empty state). +- [x] 3.3 Add `loadLogsState` helper and call it from + `openActionRow('logs')` so the entries are hydrated lazily. +- [x] 3.4 In `applyKey`, route `1`-`5` to filter swaps and `r` to + rescan when `mode === 'logs'`. + +## 4. Cleanup +- [ ] 4.1 Commit changes on the agent branch. +- [ ] 4.2 Push branch and open a PR. +- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`. +- [ ] 4.4 Record PR URL and `MERGED` evidence. diff --git a/src/cockpit/control.js b/src/cockpit/control.js index 2b17f4b..7836e71 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -8,6 +8,7 @@ const { stripAnsi } = require('./theme'); const { renderWelcomePage } = require('./welcome'); const { runCockpitAction } = require('./action-runner'); const { findProjects } = require('./projects-finder'); +const { readLogs, filterEntries, LEVELS: LOG_LEVELS } = require('./logs-reader'); const { PANE_MENU_ITEMS, applyPaneMenuKey, @@ -348,7 +349,12 @@ function openActionRow(state, actionId) { return normalizeControlState({ ...current, mode: 'shortcuts', lastIntent: null }); } if (actionId === 'logs') { - return normalizeControlState({ ...current, mode: 'logs', lastIntent: null }); + const withLogs = loadLogsState(current); + return normalizeControlState({ + ...withLogs, + mode: 'logs', + lastIntent: null, + }); } if (actionId === 'projects') { const withProjects = loadProjectsState(current); @@ -532,6 +538,19 @@ function applyKey(state, rawKey) { const refreshed = loadProjectsState(current, { refresh: true }); return normalizeControlState({ ...refreshed, lastIntent: null }); } + if (mode === 'logs') { + if (Object.prototype.hasOwnProperty.call(LOGS_FILTER_KEYS, key)) { + return normalizeControlState({ + ...current, + logsFilter: LOGS_FILTER_KEYS[key], + lastIntent: null, + }); + } + if (key === 'r') { + const refreshed = loadLogsState(current, { refresh: true }); + return normalizeControlState({ ...refreshed, lastIntent: null }); + } + } return current; } @@ -708,21 +727,83 @@ function renderTerminalPanel(state) { ].join('\n'); } +const LOGS_FILTER_KEYS = { + '1': 'all', + '2': 'info', + '3': 'warning', + '4': 'error', + '5': 'by-pane', +}; + +function loadLogsState(current, options = {}) { + if (current.logs && options.refresh !== true) { + return current; + } + const result = readLogs({ + repoRoot: current.repoPath, + fs: options.fs, + sources: options.sources, + limit: options.limit, + tailBytes: options.tailBytes, + }); + return { + ...current, + logs: result.entries, + logsCounts: result.counts, + logsSources: result.sources, + logsFilter: current.logsFilter || 'all', + }; +} + +function logsFilterLabel(filter) { + switch (filter) { + case 'info': return 'Info'; + case 'warning': return 'Warnings'; + case 'error': return 'Errors'; + case 'by-pane': return 'By Pane'; + default: return 'All'; + } +} + function renderLogsPanel(state) { const current = normalizeControlState(state); - const sessions = current.sessions.length; - return [ + const counts = current.logsCounts || { all: 0 }; + const filter = current.logsFilter || 'all'; + const entries = filterEntries(current.logs || [], filter); + const sources = Array.isArray(current.logsSources) ? current.logsSources : []; + const summary = `${counts.all || 0} total` + + ` ${counts.info || 0} info` + + ` ${counts.warning || 0} warn` + + ` ${counts.error || 0} err`; + + const lines = [ 'gitguardex logs', '', - `repo: ${current.repoPath || '-'}`, - `active lanes: ${sessions}`, + summary, + `filter: ${logsFilterLabel(filter)}`, + `sources: ${sources.length}`, '', '[1] All [2] Info [3] Warnings [4] Errors [5] By Pane', '', - 'Live tail of `apps/logs/*.log` and lane heartbeats lands here.', - 'Esc: back to main', - '', - ].join('\n'); + ]; + + if (entries.length === 0) { + lines.push(' no log entries (filter or no log files yet)'); + } else { + const tail = entries.slice(-20); + for (const entry of tail) { + const tag = entry.level === 'error' ? '[ERR]' + : entry.level === 'warning' ? '[WRN]' + : entry.level === 'debug' ? '[DBG]' + : '[INF]'; + lines.push(`${tag} ${entry.source} · ${entry.line}`); + } + } + + lines.push(''); + lines.push('r: rescan Esc: back to main'); + lines.push(''); + return lines.join('\n'); } function loadProjectsState(current, options = {}) { diff --git a/src/cockpit/logs-reader.js b/src/cockpit/logs-reader.js new file mode 100644 index 0000000..40565c6 --- /dev/null +++ b/src/cockpit/logs-reader.js @@ -0,0 +1,182 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const DEFAULT_TAIL_BYTES = 32 * 1024; +const DEFAULT_LIMIT = 200; +const DEFAULT_LOG_GLOBS = ['apps/logs', '.omc/logs', '.omx/logs']; + +const LEVELS = ['info', 'warning', 'error', 'debug']; +const LEVEL_PATTERNS = [ + { level: 'error', regex: /\b(error|err|exception|fail(?:ed|ure)?|fatal|panic|traceback)\b/i }, + { level: 'warning', regex: /\b(warn|warning|deprecated|caution)\b/i }, + { level: 'debug', regex: /\b(debug|trace|verbose)\b/i }, +]; + +function text(value, fallback = '') { + if (typeof value === 'string') return value || fallback; + if (value === null || value === undefined) return fallback; + return String(value) || fallback; +} + +function classifyLevel(line) { + for (const { level, regex } of LEVEL_PATTERNS) { + if (regex.test(line)) return level; + } + return 'info'; +} + +function pickFs(options = {}) { + return options.fs || fs; +} + +function listLogPaths(root, options = {}) { + const fsImpl = pickFs(options); + const globs = Array.isArray(options.globs) && options.globs.length > 0 ? options.globs : DEFAULT_LOG_GLOBS; + const seen = new Set(); + const paths = []; + + for (const glob of globs) { + const dir = path.isAbsolute(glob) ? glob : path.join(root, glob); + pushLogsFromDir(dir, fsImpl, seen, paths); + } + + return paths; +} + +function pushLogsFromDir(dir, fsImpl, seen, paths) { + let entries; + try { + entries = fsImpl.readdirSync(dir, { withFileTypes: true }); + } catch (_error) { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + pushLogsFromDir(full, fsImpl, seen, paths); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.log')) continue; + if (seen.has(full)) continue; + seen.add(full); + paths.push(full); + } +} + +function tailFile(file, options = {}) { + const fsImpl = pickFs(options); + const tailBytes = Number.isFinite(options.tailBytes) && options.tailBytes > 0 + ? options.tailBytes + : DEFAULT_TAIL_BYTES; + let stat; + try { + stat = fsImpl.statSync(file, { throwIfNoEntry: false }); + } catch (_error) { + return []; + } + if (!stat) return []; + + const size = Number.isFinite(stat.size) ? stat.size : 0; + const start = size > tailBytes ? size - tailBytes : 0; + + let content = ''; + try { + if (typeof fsImpl.openSync === 'function' && typeof fsImpl.readSync === 'function' && typeof fsImpl.closeSync === 'function' && start > 0) { + const fd = fsImpl.openSync(file, 'r'); + try { + const buffer = Buffer.alloc(size - start); + fsImpl.readSync(fd, buffer, 0, buffer.length, start); + content = buffer.toString('utf8'); + } finally { + fsImpl.closeSync(fd); + } + } else if (typeof fsImpl.readFileSync === 'function') { + content = fsImpl.readFileSync(file, 'utf8'); + if (content.length > tailBytes) content = content.slice(content.length - tailBytes); + } + } catch (_error) { + return []; + } + + const lines = content.split('\n'); + return lines.map((line, index) => ({ line, partial: index === 0 && start > 0 })); +} + +function readLogs(options = {}) { + const root = text(options.repoRoot || options.root || process.cwd(), process.cwd()); + const limit = Number.isFinite(options.limit) && options.limit > 0 ? options.limit : DEFAULT_LIMIT; + const sources = options.sources || listLogPaths(root, options); + const entries = []; + + for (const source of sources) { + const tail = tailFile(source, options); + const sourceName = path.relative(root, source) || source; + for (let i = 0; i < tail.length; i += 1) { + const { line, partial } = tail[i]; + if (partial && i === 0) continue; + const trimmed = line.replace(/\r$/, ''); + if (!trimmed) continue; + entries.push({ + source: sourceName, + line: trimmed, + level: classifyLevel(trimmed), + }); + } + } + + if (entries.length > limit) { + entries.splice(0, entries.length - limit); + } + + return { + entries, + sources: sources.map((source) => path.relative(root, source) || source), + counts: tallyLevels(entries), + }; +} + +function tallyLevels(entries) { + const counts = { all: entries.length }; + for (const level of LEVELS) counts[level] = 0; + for (const entry of entries) { + counts[entry.level] = (counts[entry.level] || 0) + 1; + } + return counts; +} + +function filterEntries(entries, filter) { + const value = String(filter || 'all').toLowerCase(); + if (value === 'all' || value === '') return entries.slice(); + if (value === 'by-pane' || value === 'by-source') { + const groups = new Map(); + for (const entry of entries) { + const key = entry.source || 'unknown'; + const arr = groups.get(key) || []; + arr.push(entry); + groups.set(key, arr); + } + const ordered = []; + for (const [, arr] of groups) ordered.push(...arr); + return ordered; + } + if (LEVELS.includes(value)) { + return entries.filter((entry) => entry.level === value); + } + return entries.slice(); +} + +module.exports = { + DEFAULT_LIMIT, + DEFAULT_LOG_GLOBS, + DEFAULT_TAIL_BYTES, + LEVELS, + classifyLevel, + filterEntries, + listLogPaths, + readLogs, + tailFile, + tallyLevels, +}; diff --git a/test/cockpit-logs.test.js b/test/cockpit-logs.test.js new file mode 100644 index 0000000..a186372 --- /dev/null +++ b/test/cockpit-logs.test.js @@ -0,0 +1,160 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + classifyLevel, + filterEntries, + readLogs, + tallyLevels, +} = require('../src/cockpit/logs-reader'); +const { applyCockpitAction } = require('../src/cockpit/control'); + +function fakeFs(tree) { + function lookup(p) { + const norm = p.replace(/\/+$/, ''); + return tree[norm] || null; + } + return { + statSync(p, options = {}) { + const node = lookup(p); + if (!node) { + if (options.throwIfNoEntry === false) return undefined; + const err = new Error(`ENOENT: ${p}`); + err.code = 'ENOENT'; + throw err; + } + return { + isDirectory: () => node.kind === 'dir', + isFile: () => node.kind === 'file', + size: node.kind === 'file' ? Buffer.byteLength(node.content || '', 'utf8') : 0, + }; + }, + readdirSync(p) { + const node = lookup(p); + if (!node || node.kind !== 'dir') { + const err = new Error(`ENOENT: ${p}`); + err.code = 'ENOENT'; + throw err; + } + return (node.entries || []).map((entry) => ({ + name: entry.name, + isDirectory: () => entry.kind === 'dir', + isFile: () => entry.kind === 'file', + })); + }, + readFileSync(p, _encoding) { + const node = lookup(p); + if (!node || node.kind !== 'file') { + const err = new Error(`ENOENT: ${p}`); + err.code = 'ENOENT'; + throw err; + } + return node.content || ''; + }, + }; +} + +test('classifyLevel detects errors, warnings, debug, defaults to info', () => { + assert.equal(classifyLevel('boom: error something'), 'error'); + assert.equal(classifyLevel('Exception in thread'), 'error'); + assert.equal(classifyLevel('hmm, this is a warning'), 'warning'); + assert.equal(classifyLevel('debug: connecting'), 'debug'); + assert.equal(classifyLevel('all good here'), 'info'); +}); + +test('readLogs reads tail of .log files under apps/logs and tallies levels', () => { + const tree = { + '/repo': { kind: 'dir', entries: [ + { name: 'apps', kind: 'dir' }, + { name: '.omc', kind: 'dir' }, + ] }, + '/repo/apps': { kind: 'dir', entries: [ + { name: 'logs', kind: 'dir' }, + ] }, + '/repo/apps/logs': { kind: 'dir', entries: [ + { name: 'server.log', kind: 'file' }, + { name: 'README.md', kind: 'file' }, + ] }, + '/repo/apps/logs/server.log': { + kind: 'file', + content: 'INFO ready\nERROR boom\nWARN slow query\ninfo: heartbeat\n', + }, + '/repo/apps/logs/README.md': { kind: 'file', content: 'should be ignored' }, + '/repo/.omc': { kind: 'dir', entries: [{ name: 'logs', kind: 'dir' }] }, + '/repo/.omc/logs': { kind: 'dir', entries: [] }, + }; + + const result = readLogs({ repoRoot: '/repo', fs: fakeFs(tree) }); + assert.equal(result.entries.length, 4); + assert.deepEqual(result.entries.map((e) => e.level), ['info', 'error', 'warning', 'info']); + assert.equal(result.counts.error, 1); + assert.equal(result.counts.warning, 1); + assert.equal(result.counts.info, 2); + assert.deepEqual(result.sources, ['apps/logs/server.log']); +}); + +test('filterEntries returns only matching levels and groups for by-pane', () => { + const entries = [ + { source: 'a.log', level: 'info', line: 'a-info' }, + { source: 'b.log', level: 'error', line: 'b-err' }, + { source: 'a.log', level: 'warning', line: 'a-warn' }, + { source: 'b.log', level: 'info', line: 'b-info' }, + ]; + assert.deepEqual(filterEntries(entries, 'all').map((e) => e.line), ['a-info', 'b-err', 'a-warn', 'b-info']); + assert.deepEqual(filterEntries(entries, 'error').map((e) => e.line), ['b-err']); + assert.deepEqual(filterEntries(entries, 'warning').map((e) => e.line), ['a-warn']); + assert.deepEqual(filterEntries(entries, 'by-pane').map((e) => e.line), ['a-info', 'a-warn', 'b-err', 'b-info']); +}); + +test('tallyLevels counts by level with all aggregate', () => { + const counts = tallyLevels([ + { level: 'info' }, { level: 'error' }, { level: 'error' }, { level: 'warning' }, + ]); + assert.equal(counts.all, 4); + assert.equal(counts.error, 2); + assert.equal(counts.warning, 1); + assert.equal(counts.info, 1); +}); + +test('logs mode keys 1-5 swap the active filter', () => { + const seeded = { + mode: 'logs', + sessions: [], + logs: [ + { source: 'a.log', level: 'info', line: 'a' }, + { source: 'a.log', level: 'error', line: 'b' }, + ], + logsCounts: { all: 2, info: 1, error: 1, warning: 0, debug: 0 }, + logsSources: ['a.log'], + logsFilter: 'all', + }; + assert.equal(applyCockpitAction(seeded, { type: 'key', key: '2' }).logsFilter, 'info'); + assert.equal(applyCockpitAction(seeded, { type: 'key', key: '3' }).logsFilter, 'warning'); + assert.equal(applyCockpitAction(seeded, { type: 'key', key: '4' }).logsFilter, 'error'); + assert.equal(applyCockpitAction(seeded, { type: 'key', key: '5' }).logsFilter, 'by-pane'); + assert.equal(applyCockpitAction(seeded, { type: 'key', key: '1' }).logsFilter, 'all'); +}); + +test('logs mode renders summary, filter row, and tagged entries', () => { + const { renderControlFrame } = require('../src/cockpit/control'); + const seeded = { + mode: 'logs', + sessions: [], + repoPath: '/repo', + logs: [ + { source: 'apps/logs/server.log', level: 'error', line: 'boom' }, + { source: 'apps/logs/server.log', level: 'info', line: 'ok' }, + ], + logsCounts: { all: 2, info: 1, error: 1, warning: 0, debug: 0 }, + logsSources: ['apps/logs/server.log'], + logsFilter: 'all', + }; + const frame = renderControlFrame(seeded).replace(/\x1b\[[0-9;]*m/g, ''); + assert.match(frame, /gitguardex logs/); + assert.match(frame, /\[1\] All\s+\[2\] Info\s+\[3\] Warnings\s+\[4\] Errors\s+\[5\] By Pane/); + assert.match(frame, /\[ERR\] apps\/logs\/server\.log/); + assert.match(frame, /\[INF\] apps\/logs\/server\.log/); + assert.match(frame, /r: rescan/); +});