diff --git a/artifacts/feature-previews/diagnostics-desktop.png b/artifacts/feature-previews/diagnostics-desktop.png new file mode 100644 index 00000000..b8e192a5 Binary files /dev/null and b/artifacts/feature-previews/diagnostics-desktop.png differ diff --git a/artifacts/feature-previews/diagnostics-mobile.png b/artifacts/feature-previews/diagnostics-mobile.png new file mode 100644 index 00000000..730dddfc Binary files /dev/null and b/artifacts/feature-previews/diagnostics-mobile.png differ diff --git a/server/index.js b/server/index.js index 6d9821fd..94d07691 100755 --- a/server/index.js +++ b/server/index.js @@ -23,12 +23,14 @@ import { createTmuxOutputActivityMonitor, getCurrentTmuxPaneIdentity, getCurrentTmuxPaneIdentityState, + getGjcWatcherHealth, initializeSessionsWatcher, onTranscriptChanged, readTmuxPaneIdentity, runTmux, } from '@/modules/providers/index.js'; import { createWebSocketServer } from '@/modules/websocket/index.js'; +import { createDiagnosticsRouter, createDiagnosticsService } from '@/modules/diagnostics/index.js'; import { createFleetHubLifecycle, createLocalFleetHubRuntime } from '@/modules/fleet/hub/connection/index.js'; import { createFleetPeerLifecycle, createLocalFleetPeerRuntime } from '@/modules/fleet/peer/index.js'; import { fleetRuntimeEnabled, stopFleetRuntimeServices } from '@/modules/fleet/runtime-lifecycle.js'; @@ -356,6 +358,17 @@ app.get('/health', (req, res) => { // Authentication routes (public) app.use('/api/auth', authRoutes); +// Diagnostics owns authentication so even rejected reads receive no-store. +const ownerDiagnostics = createDiagnosticsService({ + collector: () => discoveryCollector, + watcher: getGjcWatcherHealth, +}); +app.use('/api/settings/diagnostics', createDiagnosticsRouter({ + authMode: AUTH_MODE, + authenticate: authenticateToken, + read: ownerDiagnostics.read, +})); + // Machine pairing carries signed installation identity instead of browser auth. // Every other fleet-management route must first resolve the browser owner. app.use('/api/fleet', (req, res, next) => ( diff --git a/server/modules/diagnostics/README.md b/server/modules/diagnostics/README.md new file mode 100644 index 00000000..88bc11c0 --- /dev/null +++ b/server/modules/diagnostics/README.md @@ -0,0 +1,55 @@ +# Owner diagnostics API + +`GET /api/settings/diagnostics` is a local, display-only settings endpoint. +It runs the existing browser authentication middleware and fleet owner check: +password-authenticated owners, Tailscale `owner`/`local`, or an implicit owner +on a loopback connection in `none` mode. An absent principal returns 401; +an authenticated non-owner returns 403. No client-supplied role, address, +refresh flag, session identity, or revision grants access. Every response, +including authentication rejection and failures, has `Cache-Control: no-store`. +The route is mounted before generic `/api` middleware so rejection responses +also receive that header. `/health` is unchanged. + +The schema is defined in `shared/diagnostics.ts`. One service instance caches +the projected summary for 2 seconds, across callers. Reading or refreshing it +never calls discovery `tick`, `ensureFresh`, `forceRefresh`, filesystem APIs, +tmux, provider processes, watcher lifecycle methods, or action verifiers. It +does not activate idle discovery. It owns no timers or listeners, and performs +no persistence or logging. Provider failures are converted to fixed unavailable +states; an unexpected route-level failure returns 503 `diagnostics_unavailable`. + +Fields are explicitly constructed from existing cached metadata: + +- Collector timer/active/in-flight/disposed state; cached observation age; + age of the last full scan where both lanes succeeded; lane result and + consecutive failure counts. Observation freshness uses 30 seconds; bootstrap + with no observation is `waiting`, never healthy. An observation can be recent + while a lane is failing. Cheap host observations do not reset full-scan age. +- First 1,000 retained discovery rows, counted by lane and stale presence, plus + counts of the seven allowlisted `ProviderConnectionIssue` codes. Truncation is + explicit. These counts describe retained rows, not lifetime failure events. +- Existing GJC watcher failure/degraded/watch-limit signals. The accessor does + not establish watcher liveness, so zero failures is `no_failures_reported`. +- Node's platform `performance.eventLoopUtilization()` cumulative active share + since process start, rounded to four decimals. This is not CPU load, event-loop + delay, or a current latency measure. Sampling creates no histogram or timer. + +Ages are capped at seven days; counters at 1,000,000. Invalid/future observation +times yield unknown age, not fresh health. Summaries include their capture time +and cache TTL. The browser loads once per tab mount and offers a manual refresh; +it does not poll, persist results, or send scans/restarts/agent inputs. Requests +are aborted on unmount or after 10 seconds. English and Korean strings are +provided; other locales use the existing English fallback. + +Never add object spreads of provider objects, raw errors, paths, command argv, +transcripts, socket/pane/process/session identities, labels, credentials, or +tokens to this payload. These diagnostics cannot authorize or identify an action +target. Recovery guidance preserves exact-identity checks and points users to +verified terminal attach or their existing terminal when binding is uncertain. + +Focused checks use the existing test runner's `runTests` with +`server/tsconfig.json` for this module's tests and +`providers/tests/discovery-collector.service.test.ts`, and `tsconfig.json` for +`DiagnosticsSettingsTab.test.tsx` and `DiagnosticsSettingsTab.mounted.test.tsx`. +Use `npm run verify` and the repository CUA harness for full repository and +desktop/mobile browser regression checks. diff --git a/server/modules/diagnostics/diagnostics.routes.test.ts b/server/modules/diagnostics/diagnostics.routes.test.ts new file mode 100644 index 00000000..8b594780 --- /dev/null +++ b/server/modules/diagnostics/diagnostics.routes.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import test from 'node:test'; + +import express from 'express'; +import type { RequestHandler } from 'express'; + +import { createDiagnosticsRouter } from './diagnostics.routes.js'; +import { createDiagnosticsService } from './diagnostics.service.js'; + +async function fixture(options: { + authMode?: 'none' | 'password' | 'tailscale'; + remoteAddress?: string; + fail?: boolean; +} = {}) { + let reads = 0; + const app = express(); + const authenticate: RequestHandler = (request, response, next) => { + if (request.headers['x-test-auth'] === 'rejected') { + response.status(401).json({ error: 'authentication_required' }); + return; + } + if (request.headers['x-test-auth']) { + Object.defineProperty(request, 'user', { value: { + id: 1, tailscaleRole: request.headers['x-test-auth'], + } }); + } + if (options.remoteAddress) Object.defineProperty(request.socket, 'remoteAddress', { value: options.remoteAddress }); + next(); + }; + const service = createDiagnosticsService({ + collector: () => null, watcher: () => null, eventLoopUtilization: () => 0.2, + }); + app.use('/api/settings/diagnostics', createDiagnosticsRouter({ + authMode: options.authMode ?? 'tailscale', authenticate, + read: () => { + reads++; + if (options.fail) throw new Error('PRIVATE_ERROR /home/private/token'); + return service.read(); + }, + })); + const server = createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return { + url: `http://127.0.0.1:${address.port}/api/settings/diagnostics`, + reads: () => reads, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), + }; +} + +test('unauthenticated and non-owner reads fail closed with no-store before collecting data', async (context) => { + const subject = await fixture(); + context.after(subject.close); + for (const [role, status] of [['', 401], ['rejected', 401], ['user', 403], ['member', 403]] as const) { + const response = await fetch(`${subject.url}?owner=true&refresh=true`, { + headers: { 'x-test-auth': role, 'x-forwarded-for': '127.0.0.1' }, + }); + assert.equal(response.status, status); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.deepEqual(await response.json(), { error: status === 401 ? 'authentication_required' : 'owner_required' }); + } + assert.equal(subject.reads(), 0); +}); + +test('Tailscale owner/local and password principals may read a bounded summary', async (context) => { + for (const authMode of ['tailscale', 'password'] as const) { + const subject = await fixture({ authMode }); + context.after(subject.close); + for (const role of ['owner', 'local']) { + const response = await fetch(subject.url, { headers: { 'x-test-auth': role } }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const body = await response.text(); + assert.equal(JSON.parse(body).schemaVersion, 1); + assert.doesNotMatch(body, /PRIVATE|socketPath|transcriptPaths|providerSessionId|password|token/); + } + } +}); + +test('implicit ownership requires actual loopback and ignores forwarded address claims', async (context) => { + for (const [remoteAddress, status] of [['127.0.0.1', 200], ['::1', 200], ['100.64.0.9', 403]] as const) { + const subject = await fixture({ authMode: 'none', remoteAddress }); + context.after(subject.close); + const response = await fetch(subject.url, { headers: { 'x-test-auth': 'local', 'x-forwarded-for': '127.0.0.1' } }); + assert.equal(response.status, status); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(subject.reads(), status === 200 ? 1 : 0); + await response.body?.cancel(); + } +}); + +test('unexpected summary errors return generic 503 without private diagnostics', async (context) => { + const subject = await fixture({ fail: true }); + context.after(subject.close); + const response = await fetch(subject.url, { headers: { 'x-test-auth': 'owner' } }); + assert.equal(response.status, 503); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.deepEqual(await response.json(), { error: 'diagnostics_unavailable' }); +}); + +test('there is no mutation or restart API', async (context) => { + const subject = await fixture(); + context.after(subject.close); + for (const method of ['POST', 'PUT', 'DELETE']) { + const response = await fetch(subject.url, { method, headers: { 'x-test-auth': 'owner' } }); + assert.equal(response.status, 404); + await response.body?.cancel(); + } + assert.equal(subject.reads(), 0); +}); diff --git a/server/modules/diagnostics/diagnostics.routes.ts b/server/modules/diagnostics/diagnostics.routes.ts new file mode 100644 index 00000000..d3076009 --- /dev/null +++ b/server/modules/diagnostics/diagnostics.routes.ts @@ -0,0 +1,36 @@ +import express from 'express'; +import type { RequestHandler } from 'express'; + +import { authorizeFleetBrowserRequest } from '@/modules/fleet/index.js'; + +import type { OwnerDiagnostics } from '../../../shared/diagnostics.js'; + +export function createDiagnosticsRouter(dependencies: { + authMode: 'none' | 'password' | 'tailscale'; + authenticate: RequestHandler; + read: () => OwnerDiagnostics; +}): express.Router { + const router = express.Router(); + router.use((_request, response, next) => { + response.set('Cache-Control', 'no-store'); + next(); + }); + router.use(dependencies.authenticate); + router.get('/', (request, response) => { + const owner = authorizeFleetBrowserRequest(request, dependencies.authMode); + if (!('user' in request) || !request.user) { + response.status(401).json({ error: 'authentication_required' }); + return; + } + if (!owner) { + response.status(403).json({ error: 'owner_required' }); + return; + } + try { + response.json(dependencies.read()); + } catch { + response.status(503).json({ error: 'diagnostics_unavailable' }); + } + }); + return router; +} diff --git a/server/modules/diagnostics/diagnostics.service.test.ts b/server/modules/diagnostics/diagnostics.service.test.ts new file mode 100644 index 00000000..a5f59ad0 --- /dev/null +++ b/server/modules/diagnostics/diagnostics.service.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { DiscoveryCollector, DiscoveryRow } from '@/modules/providers/index.js'; + +import { + createDiagnosticsService, + DIAGNOSTICS_CACHE_TTL_MS, + DIAGNOSTICS_MAX_AGE_MS, + DIAGNOSTICS_MAX_ROWS, + type DiagnosticsDependencies, +} from './diagnostics.service.js'; + +const PRIVATE = 'PRIVATE_DIAGNOSTIC_SENTINEL'; + +function fixture() { + let now = 100_000; + let reads = 0; + const rows: DiscoveryRow[] = [{ + key: PRIVATE, lane: 'external', tmuxName: PRIVATE, + tmux: { socketPath: PRIVATE, sessionId: PRIVATE, windowId: PRIVATE, paneId: PRIVATE }, + process: { pid: 987654, startedAtMs: 123 }, kind: PRIVATE, + providerSessionId: PRIVATE, cwd: PRIVATE, lastSeenRevision: 1, + presence: 'stale', staleSinceRevision: 1, activity: 'unknown', + connectionIssue: 'transcript_permission_denied', + }]; + const detailed = { + takenAtMs: 99_000 as number | null, + external: { ok: true, sessions: [], rawError: PRIVATE }, + live: { ok: true, sessions: [], transcriptPaths: new Map([[PRIVATE, PRIVATE]]) }, + }; + const state = { + running: true, active: false, scanning: false, disposed: false, + lastFullScanAtMs: 98_000, consecutiveFailures: { external: 0, live: 0 }, + argv: PRIVATE, + }; + const health = { + external: { ok: true, lastOkRevision: 1, consecutiveFailures: 0 }, + live: { ok: true, lastOkRevision: 1, consecutiveFailures: 0 }, + }; + const collector: Pick = { + currentSnapshot: () => { reads++; return { epoch: PRIVATE, revision: 1, takenAtMs: 99_000, rows, health }; }, + currentDetailed: () => detailed, + getState: () => state, + }; + const watcher = { ok: true, degraded: false, consecutiveFailures: 0, enospcObserved: false, token: PRIVATE }; + const dependencies: DiagnosticsDependencies = { + collector: () => collector, watcher: () => watcher, now: () => now, eventLoopUtilization: () => 0.123456, + }; + return { + rows, detailed, state, health, watcher, dependencies, + setNow: (value: number) => { now = value; }, reads: () => reads, + service: createDiagnosticsService(dependencies), + }; +} + +test('cached reads project only allowlisted aggregate fields and bounded platform utilization', () => { + const subject = fixture(); + const data = subject.service.read(); + assert.equal(data.collector.mode, 'idle'); + assert.equal(data.collector.freshness, 'fresh'); + assert.equal(data.collector.scanAgeMs, 1_000); + assert.equal(data.collector.fullScanAgeMs, 2_000); + assert.deepEqual(data.collector.lanes.external, { status: 'ok', consecutiveFailures: 0, rows: 1, staleRows: 1 }); + assert.deepEqual(data.collector.connectionIssues, [{ code: 'transcript_permission_denied', count: 1 }]); + assert.equal(data.gjcWatcher.status, 'no_failures_reported'); + assert.equal(data.eventLoop.utilization, 0.1235); + const json = JSON.stringify(data); + assert.ok(json.length < 2_000); + assert.doesNotMatch(json, /PRIVATE_DIAGNOSTIC_SENTINEL|987654|socketPath|providerSessionId|transcriptPaths|argv|rawError|token|cwd|epoch/); +}); + +test('all callers share a two-second cache without invoking collector mutations', () => { + const subject = fixture(); + const first = subject.service.read(); + subject.state.consecutiveFailures.external = 1; + subject.setNow(100_000 + DIAGNOSTICS_CACHE_TTL_MS - 1); + for (let i = 0; i < 20; i++) assert.equal(subject.service.read(), first); + assert.equal(subject.reads(), 1); + subject.setNow(100_000 + DIAGNOSTICS_CACHE_TTL_MS); + const second = subject.service.read(); + assert.notEqual(second, first); + assert.equal(second.collector.lanes.external.status, 'failing'); + assert.equal(first.collector.lanes.external.consecutiveFailures, 0); + assert.equal(subject.reads(), 2); + subject.setNow(50_000); + assert.notEqual(subject.service.read(), second, 'clock rollback must expire the cache'); +}); + +test('bootstrap, stale observations, failed lanes, and successful full scan ages remain distinct', () => { + const subject = fixture(); + subject.detailed.takenAtMs = null; + assert.equal(subject.service.read().collector.freshness, 'waiting'); + subject.setNow(150_000); + subject.detailed.takenAtMs = 99_000; + subject.detailed.external.ok = false; + subject.state.consecutiveFailures.external = 4; + const failing = subject.service.read().collector; + assert.equal(failing.freshness, 'stale'); + assert.equal(failing.lanes.external.status, 'failing'); + assert.equal(failing.lanes.external.consecutiveFailures, 4); + assert.equal(failing.fullScanAgeMs, 52_000); + subject.health.external.ok = false; + subject.setNow(152_000); + assert.equal(subject.service.read().collector.lanes.external.status, 'degraded'); +}); + +test('unknown reasons are omitted and retained row work and response size stay bounded', () => { + const subject = fixture(); + subject.rows.push({ ...subject.rows[0], connectionIssue: PRIVATE } as unknown as DiscoveryRow); + subject.rows.push(...Array.from({ length: 2_000 }, () => subject.rows[0])); + const data = subject.service.read(); + assert.equal(data.collector.rowsTruncated, true); + assert.equal(data.collector.lanes.external.rows, DIAGNOSTICS_MAX_ROWS); + assert.deepEqual(data.collector.connectionIssues, [{ code: 'transcript_permission_denied', count: DIAGNOSTICS_MAX_ROWS - 1 }]); + assert.doesNotMatch(JSON.stringify(data), new RegExp(PRIVATE)); +}); + +test('source failures remain independent, generic, cached, and silent', () => { + const subject = fixture(); + subject.dependencies.collector = () => { throw new Error(PRIVATE); }; + let calls = 0; + subject.dependencies.watcher = () => { calls++; throw new Error(PRIVATE); }; + subject.dependencies.eventLoopUtilization = () => { throw new Error(PRIVATE); }; + const service = createDiagnosticsService(subject.dependencies); + const data = service.read(); + assert.equal(data.collector.status, 'unavailable'); + assert.equal(data.gjcWatcher.status, 'unavailable'); + assert.equal(data.eventLoop.utilization, null); + assert.equal(service.read(), data); + assert.equal(calls, 1); + assert.doesNotMatch(JSON.stringify(data), new RegExp(PRIVATE)); + subject.dependencies.watcher = () => subject.watcher; + subject.setNow(102_000); + assert.equal(service.read().gjcWatcher.status, 'no_failures_reported'); +}); + +test('watcher degradation and watch limits are reported without claiming liveness', () => { + const subject = fixture(); + subject.watcher.ok = false; + subject.watcher.consecutiveFailures = 3; + assert.equal(subject.service.read().gjcWatcher.status, 'retrying'); + subject.watcher.degraded = true; + subject.watcher.enospcObserved = true; + subject.watcher.consecutiveFailures = 9_999_999; + subject.setNow(102_000); + assert.deepEqual(subject.service.read().gjcWatcher, { + status: 'degraded', consecutiveFailures: 1_000_000, watchLimitObserved: true, + }); +}); + +test('invalid numeric signals are unavailable and observation ages are capped', () => { + for (const timestamp of [NaN, Infinity, -1, 100_001]) { + const subject = fixture(); + subject.detailed.takenAtMs = timestamp; + subject.state.consecutiveFailures.external = NaN; + subject.dependencies.eventLoopUtilization = () => Infinity; + const data = createDiagnosticsService(subject.dependencies).read(); + assert.equal(data.collector.scanAgeMs, null); + assert.equal(data.collector.freshness, 'unavailable'); + assert.equal(data.collector.lanes.external.consecutiveFailures, 0); + assert.equal(data.eventLoop.utilization, null); + } + const subject = fixture(); + subject.setNow(DIAGNOSTICS_MAX_AGE_MS * 2); + assert.equal(subject.service.read().collector.scanAgeMs, DIAGNOSTICS_MAX_AGE_MS); +}); + +test('missing collector accessors remain unknown and stopped/disposed state is explicit', () => { + const subject = fixture(); + subject.state.running = false; + assert.equal(subject.service.read().collector.mode, 'stopped'); + subject.state.disposed = true; + subject.setNow(102_000); + assert.equal(subject.service.read().collector.mode, 'disposed'); + const collector = subject.dependencies.collector(); + assert.ok(collector); + delete collector.getState; + subject.setNow(104_000); + assert.equal(subject.service.read().collector.mode, 'unknown'); +}); diff --git a/server/modules/diagnostics/diagnostics.service.ts b/server/modules/diagnostics/diagnostics.service.ts new file mode 100644 index 00000000..28ecfcd1 --- /dev/null +++ b/server/modules/diagnostics/diagnostics.service.ts @@ -0,0 +1,128 @@ +import { performance } from 'node:perf_hooks'; + +import type { DiscoveryCollector, GjcWatcherHealth } from '@/modules/providers/index.js'; + +import type { DiagnosticsLane, OwnerDiagnostics } from '../../../shared/diagnostics.js'; +import { PROVIDER_CONNECTION_ISSUE_CODES } from '../../../shared/provider-connection.js'; + +export const DIAGNOSTICS_CACHE_TTL_MS = 2_000; +export const DIAGNOSTICS_STALE_AFTER_MS = 30_000; +export const DIAGNOSTICS_MAX_ROWS = 1_000; +export const DIAGNOSTICS_MAX_AGE_MS = 7 * 24 * 60 * 60_000; +const MAX_COUNT = 1_000_000; + +type CachedCollector = Pick; +export type DiagnosticsDependencies = { + collector: () => CachedCollector | null | undefined; + watcher: () => GjcWatcherHealth | null | undefined; + now?: () => number; + eventLoopUtilization?: () => number; +}; + +function count(value: number): number { + return Number.isFinite(value) ? Math.min(MAX_COUNT, Math.max(0, Math.floor(value))) : 0; +} + +function age(now: number, takenAt: number | null | undefined): number | null { + if (typeof takenAt !== 'number' || !Number.isFinite(takenAt) || takenAt < 0 || takenAt > now) return null; + return Math.min(DIAGNOSTICS_MAX_AGE_MS, Math.floor(now - takenAt)); +} + +function waitingLane(): DiagnosticsLane { + return { status: 'waiting', consecutiveFailures: 0, rows: 0, staleRows: 0 }; +} + +function unavailableCollector(): OwnerDiagnostics['collector'] { + return { + status: 'unavailable', mode: 'unknown', scanning: false, freshness: 'unavailable', + scanAgeMs: null, fullScanAgeMs: null, staleAfterMs: DIAGNOSTICS_STALE_AFTER_MS, + rowsTruncated: false, lanes: { external: waitingLane(), live: waitingLane() }, connectionIssues: [], + }; +} + +function summarizeCollector(collector: CachedCollector | null | undefined, now: number): OwnerDiagnostics['collector'] { + if (!collector) return unavailableCollector(); + // These methods only return existing metadata. Do not use ensureFresh/tick here. + const snapshot = collector.currentSnapshot(); + const detailed = collector.currentDetailed(); + const state = collector.getState?.(); + const scanAgeMs = age(now, detailed.takenAtMs); + const lanes = { external: waitingLane(), live: waitingLane() }; + for (const lane of ['external', 'live'] as const) { + const failures = count(state?.consecutiveFailures[lane] ?? snapshot.health[lane].consecutiveFailures); + lanes[lane].consecutiveFailures = failures; + lanes[lane].status = detailed[lane] === null ? 'waiting' + : !snapshot.health[lane].ok ? 'degraded' + : detailed[lane].ok === false || failures > 0 ? 'failing' : 'ok'; + } + const issueCounts = new Map(); + for (const row of snapshot.rows.slice(0, DIAGNOSTICS_MAX_ROWS)) { + if (row.lane !== 'external' && row.lane !== 'live') continue; + lanes[row.lane].rows += 1; + if (row.presence === 'stale') lanes[row.lane].staleRows += 1; + if (typeof row.connectionIssue === 'string') { + // Only fixed, known reason codes survive the projection below. + if (PROVIDER_CONNECTION_ISSUE_CODES.some((code) => code === row.connectionIssue)) { + issueCounts.set(row.connectionIssue, (issueCounts.get(row.connectionIssue) ?? 0) + 1); + } + } + } + return { + status: 'available', + mode: !state ? 'unknown' : state.disposed ? 'disposed' : !state.running ? 'stopped' : state.active ? 'active' : 'idle', + scanning: state?.scanning === true, + freshness: detailed.takenAtMs === null ? 'waiting' + : scanAgeMs === null ? 'unavailable' : scanAgeMs > DIAGNOSTICS_STALE_AFTER_MS ? 'stale' : 'fresh', + scanAgeMs, + fullScanAgeMs: age(now, state?.lastFullScanAtMs), + staleAfterMs: DIAGNOSTICS_STALE_AFTER_MS, + rowsTruncated: snapshot.rows.length > DIAGNOSTICS_MAX_ROWS, + lanes, + connectionIssues: PROVIDER_CONNECTION_ISSUE_CODES.flatMap((code) => { + const total = issueCounts.get(code) ?? 0; + return total > 0 ? [{ code, count: total }] : []; + }), + }; +} + +/** A bounded in-memory view. Owns no timers, listeners, subprocesses, or I/O. */ +export function createDiagnosticsService(dependencies: DiagnosticsDependencies) { + const now = dependencies.now ?? Date.now; + const utilization = dependencies.eventLoopUtilization ?? (() => performance.eventLoopUtilization().utilization); + let cached: OwnerDiagnostics | undefined; + let cachedAt = 0; + return { + read(): OwnerDiagnostics { + const sampledAt = now(); + if (cached && sampledAt >= cachedAt && sampledAt - cachedAt < DIAGNOSTICS_CACHE_TTL_MS) return cached; + let collector = unavailableCollector(); + let gjcWatcher: OwnerDiagnostics['gjcWatcher'] = { + status: 'unavailable', consecutiveFailures: 0, watchLimitObserved: false, + }; + let eventLoopUtilization: number | null = null; + // One broken source must not hide the remaining recovery signals. Never + // serialize/log exception text: providers may include paths or secrets. + try { collector = summarizeCollector(dependencies.collector(), sampledAt); } catch { /* unavailable */ } + try { + const watcher = dependencies.watcher(); + if (watcher) gjcWatcher = { + status: watcher.degraded === true ? 'degraded' + : watcher.ok === false || watcher.consecutiveFailures > 0 ? 'retrying' : 'no_failures_reported', + consecutiveFailures: count(watcher.consecutiveFailures), + watchLimitObserved: watcher.enospcObserved === true, + }; + } catch { /* unavailable */ } + try { + const value = utilization(); + if (Number.isFinite(value) && value >= 0 && value <= 1) eventLoopUtilization = Math.round(value * 10_000) / 10_000; + } catch { /* unavailable */ } + cachedAt = sampledAt; + cached = { + schemaVersion: 1, generatedAtMs: Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(sampledAt))), + cacheTtlMs: DIAGNOSTICS_CACHE_TTL_MS, collector, gjcWatcher, + eventLoop: { utilization: eventLoopUtilization }, + }; + return cached; + }, + }; +} diff --git a/server/modules/diagnostics/index.ts b/server/modules/diagnostics/index.ts new file mode 100644 index 00000000..6b54dd8a --- /dev/null +++ b/server/modules/diagnostics/index.ts @@ -0,0 +1,2 @@ +export { createDiagnosticsRouter } from './diagnostics.routes.js'; +export { createDiagnosticsService } from './diagnostics.service.js'; diff --git a/server/modules/providers/index.ts b/server/modules/providers/index.ts index 7e44f0c0..c84a83e5 100644 --- a/server/modules/providers/index.ts +++ b/server/modules/providers/index.ts @@ -12,6 +12,7 @@ export { cursorCliCommandOrDefault } from './list/cursor/cursor-cli-command.js'; export { initializeSessionsWatcher } from './services/sessions-watcher.service.js'; export { closeSessionsWatcher } from './services/sessions-watcher.service.js'; +export { getGjcWatcherHealth, type GjcWatcherHealth } from './services/sessions-watcher.service.js'; export { onTranscriptChanged, transcriptChangeVersion, diff --git a/server/modules/providers/services/discovery-collector.service.ts b/server/modules/providers/services/discovery-collector.service.ts index 7b04c120..300c79f4 100644 --- a/server/modules/providers/services/discovery-collector.service.ts +++ b/server/modules/providers/services/discovery-collector.service.ts @@ -109,6 +109,15 @@ export type DiscoveryCollector = { ensureFresh(maxAgeMs: number, forceFull?: boolean): Promise; currentSnapshot(): DiscoverySnapshot; currentDetailed(): DiscoveryDetailedSnapshot; + /** Read-only operational metadata; never starts or refreshes discovery. */ + getState?(): Readonly<{ + running: boolean; + active: boolean; + scanning: boolean; + disposed: boolean; + lastFullScanAtMs: number | null; + consecutiveFailures: Readonly>; + }>; onSnapshot(listener: (snapshot: DiscoverySnapshot) => void): () => void; }; @@ -531,6 +540,17 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} }, currentSnapshot: () => snapshot, currentDetailed: () => detailed, + getState: () => ({ + running: timer !== null, + active, + scanning: currentTick !== null, + disposed, + lastFullScanAtMs, + consecutiveFailures: { + external: laneState.external.failures, + live: laneState.live.failures, + }, + }), onSnapshot(listener) { listeners.add(listener); return () => listeners.delete(listener); diff --git a/server/modules/providers/tests/discovery-collector.service.test.ts b/server/modules/providers/tests/discovery-collector.service.test.ts index 77e45785..487f9940 100644 --- a/server/modules/providers/tests/discovery-collector.service.test.ts +++ b/server/modules/providers/tests/discovery-collector.service.test.ts @@ -46,6 +46,52 @@ function scans() { }; } +test('operational state reads are inert and expose pre-degradation failures and lifecycle', async () => { + let scansStarted = 0; + let release: (() => void) | undefined; + const timers = new Set>(); + const collector = createDiscoveryCollector({ + scanExternal: async () => { + scansStarted++; + await new Promise((resolve) => { release = resolve; }); + return { ok: false, sessions: [] }; + }, + scanLive: async () => ({ ok: true, sessions: [] }), + setTimer: () => { + const timer = { fake: true } as unknown as ReturnType; + timers.add(timer); + return timer; + }, + clearTimer: (timer) => { timers.delete(timer); }, + }); + assert.ok(collector.getState); + for (let i = 0; i < 10; i++) assert.equal(collector.getState().running, false); + assert.equal(scansStarted, 0); + assert.equal(timers.size, 0); + collector.start(); + collector.setActive(true); + assert.equal(collector.getState().running, true); + assert.equal(collector.getState().active, true); + assert.equal(scansStarted, 0); + const pending = collector.tick(); + assert.equal(collector.getState().scanning, true); + assert.ok(release); + release(); + await pending; + assert.equal(collector.getState().scanning, false); + assert.equal(collector.getState().consecutiveFailures.external, 1); + assert.equal(collector.currentSnapshot().health.external.consecutiveFailures, 0); + assert.equal(collector.getState().lastFullScanAtMs, null); + const detached = collector.getState(); + Object.assign(detached.consecutiveFailures, { external: 999 }); + assert.equal(collector.getState().consecutiveFailures.external, 1); + collector.dispose(); + assert.equal(collector.getState().disposed, true); + assert.equal(collector.getState().running, false); + assert.equal(timers.size, 0); + assert.equal(scansStarted, 1); +}); + test('discovery collector only advances revision for a changed snapshot', async () => { const state = scans(); await state.collector.tick(); diff --git a/shared/diagnostics.ts b/shared/diagnostics.ts new file mode 100644 index 00000000..24967220 --- /dev/null +++ b/shared/diagnostics.ts @@ -0,0 +1,37 @@ +import type { ProviderConnectionIssue } from './provider-connection.js'; + +/** Display-only aggregates. No session, pane, process, or action identities. */ +export type DiagnosticsLane = { + status: 'waiting' | 'ok' | 'failing' | 'degraded'; + consecutiveFailures: number; + rows: number; + staleRows: number; +}; + +export type OwnerDiagnostics = { + schemaVersion: 1; + generatedAtMs: number; + cacheTtlMs: number; + collector: { + status: 'available' | 'unavailable'; + mode: 'active' | 'idle' | 'stopped' | 'disposed' | 'unknown'; + scanning: boolean; + freshness: 'waiting' | 'fresh' | 'stale' | 'unavailable'; + scanAgeMs: number | null; + fullScanAgeMs: number | null; + staleAfterMs: number; + rowsTruncated: boolean; + lanes: Record<'external' | 'live', DiagnosticsLane>; + connectionIssues: { code: ProviderConnectionIssue; count: number }[]; + }; + gjcWatcher: { + /** The existing getter reports failures, not proof of a running watcher. */ + status: 'no_failures_reported' | 'retrying' | 'degraded' | 'unavailable'; + consecutiveFailures: number; + watchLimitObserved: boolean; + }; + eventLoop: { + /** Cumulative since process start; not CPU usage or a latency measurement. */ + utilization: number | null; + }; +}; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index 106e93d4..9ad23d45 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -58,7 +58,7 @@ type NotificationPreferencesResponse = { type ActiveLoginProvider = AgentProvider | ''; -const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'access']; +const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'access', 'diagnostics']; const normalizeMainTab = (tab: string): SettingsMainTab => { // Keep backwards compatibility with older callers that still pass "tools". diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index b5c948a6..549a4bf9 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -4,7 +4,7 @@ import type { LLMProvider } from '../../../types/app'; import type { ProviderAuthStatus } from '../../provider-auth/types'; import type { InterfaceFontSize } from '../../../utils/interfaceFontSize'; -export type SettingsMainTab = 'agents' | 'appearance' | 'access' | 'fleet'; +export type SettingsMainTab = 'agents' | 'appearance' | 'access' | 'fleet' | 'diagnostics'; export type AgentProvider = LLMProvider; export type AgentCategory = 'account' | 'permissions'; export type ProjectSortOrder = 'name' | 'date'; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 4c816107..5b22bb05 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -9,6 +9,7 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab'; import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import AccessSettingsTab from '../view/tabs/AccessSettingsTab'; import { FleetSettingsTab } from '../view/tabs/FleetSettingsTab'; +import DiagnosticsSettingsTab from '../view/tabs/DiagnosticsSettingsTab'; import { useAuth } from '../../auth'; import { isFleetOwner } from '../fleet/fleetOwner'; import { useSettingsController } from '../hooks/useSettingsController'; @@ -77,7 +78,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set }, [fleetOwner, initialTab, isOpen, setActiveTab]); useEffect(() => { - if (!fleetOwner && activeTab === 'fleet') setActiveTab('appearance'); + if (!fleetOwner && (activeTab === 'fleet' || activeTab === 'diagnostics')) setActiveTab('appearance'); }, [activeTab, fleetOwner, setActiveTab]); useEffect(() => { @@ -165,6 +166,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set {activeTab === 'access' && } {activeTab === 'fleet' && fleetOwner && } + {activeTab === 'diagnostics' && fleetOwner && } diff --git a/src/components/settings/view/SettingsSidebar.tsx b/src/components/settings/view/SettingsSidebar.tsx index 798a323d..15af1d91 100644 --- a/src/components/settings/view/SettingsSidebar.tsx +++ b/src/components/settings/view/SettingsSidebar.tsx @@ -1,4 +1,4 @@ -import { Bot, Network, Palette, ShieldCheck } from 'lucide-react'; +import { Activity, Bot, Network, Palette, ShieldCheck } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '../../../lib/utils'; @@ -22,11 +22,12 @@ const NAV_ITEMS: NavItem[] = [ { id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette }, { id: 'access', labelKey: 'mainTabs.access', icon: ShieldCheck }, { id: 'fleet', labelKey: 'mainTabs.fleet', icon: Network }, + { id: 'diagnostics', labelKey: 'diagnostics.title', icon: Activity }, ]; export default function SettingsSidebar({ activeTab, onChange, fleetOwner }: SettingsSidebarProps) { const { t } = useTranslation('settings'); - const items = fleetOwner ? NAV_ITEMS : NAV_ITEMS.filter((item) => item.id !== 'fleet'); + const items = fleetOwner ? NAV_ITEMS : NAV_ITEMS.filter((item) => item.id !== 'fleet' && item.id !== 'diagnostics'); return ( <> diff --git a/src/components/settings/view/tabs/DiagnosticsSettingsTab.mounted.test.tsx b/src/components/settings/view/tabs/DiagnosticsSettingsTab.mounted.test.tsx new file mode 100644 index 00000000..50a39fa0 --- /dev/null +++ b/src/components/settings/view/tabs/DiagnosticsSettingsTab.mounted.test.tsx @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { I18nextProvider } from 'react-i18next'; +import TestRenderer, { act } from 'react-test-renderer'; + +import DiagnosticsSettingsTab from './DiagnosticsSettingsTab'; +import { i18n, summary } from './diagnostics.testSupport'; + +test('mounted settings loads once, refreshes only the cached GET, and clears data on owner denial', async (context) => { + const calls: { url: string; options?: RequestInit }[] = []; + let response = new Response(JSON.stringify(summary())); + context.mock.method(globalThis, 'fetch', async (url: string, options?: RequestInit) => { + calls.push({ url, options }); + return response; + }); + let tree: TestRenderer.ReactTestRenderer; + await act(async () => { tree = TestRenderer.create(); }); + context.after(() => { act(() => tree.unmount()); }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, '/api/settings/diagnostics'); + assert.equal(calls[0].options?.cache, 'no-store'); + assert.equal(calls[0].options?.credentials, 'same-origin'); + assert.equal(calls[0].options?.method, undefined); + assert.match(JSON.stringify(tree!.toJSON()), /4 cached rows/); + response = new Response('PRIVATE_ERROR token', { status: 403 }); + await act(async () => { tree.root.findByType('button').props.onClick(); }); + assert.equal(calls.length, 2); + assert.match(JSON.stringify(tree!.toJSON()), /Sign in as this server/); + assert.doesNotMatch(JSON.stringify(tree!.toJSON()), /4 cached rows|PRIVATE_ERROR/); +}); + +test('network and unsupported response failures are generic and refresh can recover', async (context) => { + let mode: 'failure' | 'unsupported' | 'success' = 'failure'; + context.mock.method(globalThis, 'fetch', async () => { + if (mode === 'failure') throw new Error('PRIVATE_ERROR /home/secret token'); + return new Response(JSON.stringify(mode === 'unsupported' ? { schemaVersion: 2 } : summary())); + }); + let tree: TestRenderer.ReactTestRenderer; + await act(async () => { tree = TestRenderer.create(); }); + context.after(() => { act(() => tree.unmount()); }); + for (const next of ['unsupported', 'success'] as const) { + assert.match(JSON.stringify(tree!.toJSON()), /Diagnostics could not be read/); + assert.doesNotMatch(JSON.stringify(tree!.toJSON()), /PRIVATE_ERROR|\/home\/secret/); + mode = next; + await act(async () => { tree.root.findByType('button').props.onClick(); }); + } + assert.match(JSON.stringify(tree!.toJSON()), /4 cached rows/); + assert.doesNotMatch(JSON.stringify(tree!.toJSON()), /Diagnostics could not be read/); +}); + +test('loading disables refresh and closing settings aborts the pending request', async (context) => { + let signal: AbortSignal | undefined; + context.mock.method(globalThis, 'fetch', (_url: string, options: RequestInit) => new Promise((_resolve, reject) => { + signal = options.signal ?? undefined; + signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + })); + let tree: TestRenderer.ReactTestRenderer; + await act(async () => { tree = TestRenderer.create(); }); + assert.equal(tree!.root.findByType('button').props.disabled, true); + assert.equal(tree!.root.findAllByProps({ role: 'status' }).length, 1); + await act(async () => { tree.unmount(); }); + assert.equal(signal?.aborted, true); +}); diff --git a/src/components/settings/view/tabs/DiagnosticsSettingsTab.test.tsx b/src/components/settings/view/tabs/DiagnosticsSettingsTab.test.tsx new file mode 100644 index 00000000..5d2cbc4c --- /dev/null +++ b/src/components/settings/view/tabs/DiagnosticsSettingsTab.test.tsx @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { I18nextProvider } from 'react-i18next'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import type { OwnerDiagnostics } from '../../../../../shared/diagnostics'; +import enSettings from '../../../../i18n/locales/en/settings.json'; +import SettingsSidebar from '../SettingsSidebar'; + +import { DiagnosticsSummary } from './DiagnosticsSettingsTab'; +import { i18n, summary } from './diagnostics.testSupport'; + +function renderSummary(data: OwnerDiagnostics) { + return renderToStaticMarkup(); +} + +test('normal summary renders sample age, both lanes, liveness limits, and terminal guidance', () => { + const html = renderSummary(summary()); + for (const text of [ + 'Summary captured at', 'External CLI sessions', 'Live GJC sessions', + '4 cached rows', '1 marked stale', '25%', 'No failures reported', + 'does not prove that the watcher is running', 'verified terminal attach', + ]) assert.ok(html.includes(text), text); + assert.ok(html.includes('grid-cols-1') && html.includes('sm:grid-cols-2'), 'single-column cards on small screens'); + assert.doesNotMatch(html, /diagnostics\.(?:recovery|lanes|watcherStates|modes)/); +}); + +test('degraded discovery, retained reasons, and watch limits render useful recovery without action controls', () => { + const data = summary(); + data.collector.freshness = 'stale'; + data.collector.lanes.external = { status: 'failing', consecutiveFailures: 3, rows: 4, staleRows: 1 }; + data.collector.rowsTruncated = true; + data.collector.connectionIssues = [{ code: 'transcript_ambiguous', count: 2 }]; + data.gjcWatcher = { status: 'degraded', consecutiveFailures: 20, watchLimitObserved: true }; + const html = renderSummary(data); + for (const text of ['Older than 30 seconds', '3 consecutive failures', 'chatmux status', 'Transcript is ambiguous (2)', 'inotify', 'first 1,000']) { + assert.ok(html.includes(text), text); + } + assert.doesNotMatch(html, / + {loading &&

{t('diagnostics.loading')}

} + + {error &&

{t(`diagnostics.errors.${error}`)}

} + {data && } + + ); +} diff --git a/src/components/settings/view/tabs/diagnostics.testSupport.ts b/src/components/settings/view/tabs/diagnostics.testSupport.ts new file mode 100644 index 00000000..26544bf1 --- /dev/null +++ b/src/components/settings/view/tabs/diagnostics.testSupport.ts @@ -0,0 +1,29 @@ +import i18next from 'i18next'; + +import type { OwnerDiagnostics } from '../../../../../shared/diagnostics'; +import enSettings from '../../../../i18n/locales/en/settings.json'; +import koSettings from '../../../../i18n/locales/ko/settings.json'; + +export const i18n = i18next.createInstance(); +await i18n.init({ + lng: 'en', fallbackLng: 'en', + resources: { en: { settings: enSettings }, ko: { settings: koSettings } }, + interpolation: { escapeValue: false }, +}); + +export function summary(): OwnerDiagnostics { + return { + schemaVersion: 1, generatedAtMs: 100_000, cacheTtlMs: 2_000, + collector: { + status: 'available', mode: 'active', scanning: false, freshness: 'fresh', + scanAgeMs: 1_000, fullScanAgeMs: 8_000, staleAfterMs: 30_000, rowsTruncated: false, + lanes: { + external: { status: 'ok', consecutiveFailures: 0, rows: 4, staleRows: 1 }, + live: { status: 'ok', consecutiveFailures: 0, rows: 2, staleRows: 0 }, + }, + connectionIssues: [], + }, + gjcWatcher: { status: 'no_failures_reported', consecutiveFailures: 0, watchLimitObserved: false }, + eventLoop: { utilization: 0.25 }, + }; +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 3f507474..3f324103 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,4 +1,100 @@ { + "diagnostics": { + "title": "Diagnostics", + "description": "Cached diagnostics for this ChatMux server. Refresh reads the summary cache (up to 2 seconds old) without starting a scan or changing running sessions.", + "refresh": "Refresh summary", + "loading": "Loading diagnostics…", + "unknown": "Unavailable", + "sampledAt": "Summary captured at {{time}}. Ages below are measured at that time.", + "seconds_one": "{{count}} second", + "seconds_other": "{{count}} seconds", + "discovery": "Session discovery", + "freshness": "Latest discovery observation", + "mode": "Collector mode", + "scanAge": "Observation age", + "fullScanAge": "Last successful full scan age", + "scanning": "Discovery is currently in progress.", + "freshnessStates": { + "waiting": "No observation yet", + "fresh": "Recent (within 30 seconds)", + "stale": "Older than 30 seconds", + "unavailable": "Unavailable" + }, + "modes": { + "active": "Active", + "idle": "Idle", + "stopped": "Stopped", + "disposed": "Disposed", + "unknown": "Unavailable" + }, + "lanes": { + "external": "External CLI sessions", + "live": "Live GJC sessions" + }, + "laneStates": { + "waiting": "No scan result yet", + "ok": "Last scan succeeded", + "failing": "Scan failures observed", + "degraded": "Discovery degraded" + }, + "rowCounts": "{{rows}} cached rows · {{stale}} marked stale", + "failures_one": "{{count}} consecutive failure", + "failures_other": "{{count}} consecutive failures", + "truncated": "Counts cover the first 1,000 cached rows. Larger inventories are truncated.", + "connectionIssues": "Connection reasons", + "issueDescription": "Counts describe retained cached rows, including stale rows. No session names or paths are included.", + "watcher": "GJC transcript watcher", + "watcherDescription": "Failure signals from the existing watcher. No reported failures does not prove that the watcher is running.", + "watcherStates": { + "no_failures_reported": "No failures reported", + "retrying": "Failures reported; retry backoff", + "degraded": "Degraded; slow retry backoff", + "unavailable": "Watcher status unavailable" + }, + "eventLoop": "Server event loop utilization", + "eventLoopDescription": "Cumulative active share since server start. This is neither CPU usage nor a measurement of current response latency.", + "errors": { + "owner": "Sign in as this server’s owner to view diagnostics. For local-only access, open ChatMux on the server itself.", + "unavailable": "Diagnostics could not be read. Check your connection and refresh the summary. If this continues, run chatmux status on this server." + }, + "recovery": { + "waiting": "Open the session list to use normal discovery, then return here and refresh the summary. An unused collector may have no observation yet.", + "discovery": "Check whether the session list reconnects, then refresh this summary. If discovery remains stale or fails, run chatmux status on this server and inspect its local service logs. Keep existing tmux work running.", + "watchLimit": "The watcher reported a system watch limit. Ask the host administrator to review inotify watch and instance limits and other watcher processes. Repeated restarts do not resolve an exhausted limit.", + "watcher": "Check the GJC installation and ChatMux service logs locally. The watcher owns its retry schedule; this screen does not restart it. Existing tmux sessions can keep running.", + "terminal": "When a transcript cannot be verified, use ChatMux’s verified terminal attach. If attach is refused, use your existing terminal and check the exact tmux pane before typing." + }, + "reasons": { + "agent_user_mismatch": { + "title": "Agent user differs", + "guidance": "Check that ChatMux and the agent run as the intended OS user. Use the agent’s existing terminal until that identity can be verified." + }, + "agent_home_mismatch": { + "title": "Agent home differs", + "guidance": "Check the service account and home configuration against the agent’s environment. Do not copy credentials or transcripts into another account to bypass the mismatch." + }, + "agent_context_unreadable": { + "title": "Agent context unreadable", + "guidance": "Review process visibility and permissions on the host. Keep the session in its existing terminal while identity evidence is unavailable." + }, + "tmux_socket_owner_mismatch": { + "title": "tmux owner differs", + "guidance": "Check the account that owns the tmux server. Connect with the intended account instead of relaxing socket permissions." + }, + "tmux_pane_ambiguous": { + "title": "tmux pane is ambiguous", + "guidance": "Inspect the intended pane in your existing terminal. A matching working directory alone cannot select a safe target." + }, + "transcript_ambiguous": { + "title": "Transcript is ambiguous", + "guidance": "Use verified terminal attach while the provider session association is uncertain. Do not select a transcript based only on its directory." + }, + "transcript_permission_denied": { + "title": "Transcript access denied", + "guidance": "Review the intended service account’s access to the provider store locally. Use verified terminal attach without making private transcripts broadly readable." + } + } + }, "title": "Settings", "tabs": { "account": "Account", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 096019e2..b4a94e1a 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -1,4 +1,98 @@ { + "diagnostics": { + "title": "진단", + "description": "현재 ChatMux 서버의 캐시된 진단 정보입니다. 새로고침은 최대 2초 된 요약 캐시를 읽으며, 스캔을 시작하거나 실행 중인 세션을 변경하지 않습니다.", + "refresh": "요약 새로고침", + "loading": "진단 정보 불러오는 중…", + "unknown": "확인 불가", + "sampledAt": "{{time}}에 수집한 요약입니다. 아래 경과 시간은 수집 시점 기준입니다.", + "seconds_other": "{{count}}초", + "discovery": "세션 탐색", + "freshness": "최근 탐색 관측", + "mode": "수집기 모드", + "scanAge": "관측 후 경과 시간", + "fullScanAge": "마지막 전체 스캔 성공 후 경과 시간", + "scanning": "탐색이 진행 중입니다.", + "freshnessStates": { + "waiting": "아직 관측 없음", + "fresh": "최근 30초 이내", + "stale": "30초 이상 경과", + "unavailable": "확인 불가" + }, + "modes": { + "active": "활성", + "idle": "유휴", + "stopped": "중지", + "disposed": "종료됨", + "unknown": "확인 불가" + }, + "lanes": { + "external": "외부 CLI 세션", + "live": "실시간 GJC 세션" + }, + "laneStates": { + "waiting": "아직 스캔 결과 없음", + "ok": "마지막 스캔 성공", + "failing": "스캔 실패 감지", + "degraded": "탐색 기능 저하" + }, + "rowCounts": "캐시된 항목 {{rows}}개 · 오래된 항목 {{stale}}개", + "failures_other": "연속 실패 {{count}}회", + "truncated": "개수는 캐시의 처음 1,000개 항목 기준입니다. 나머지 항목은 집계에서 제외됩니다.", + "connectionIssues": "연결 문제 원인", + "issueDescription": "오래된 항목을 포함한 캐시 기준 개수입니다. 세션 이름이나 경로는 포함하지 않습니다.", + "watcher": "GJC 대화 기록 감시", + "watcherDescription": "기존 감시기가 보고한 실패 정보입니다. 실패 보고가 없어도 감시기의 실행 여부를 보장하지는 않습니다.", + "watcherStates": { + "no_failures_reported": "보고된 실패 없음", + "retrying": "실패 보고됨 · 재시도 대기", + "degraded": "기능 저하 · 긴 간격으로 재시도", + "unavailable": "감시 상태 확인 불가" + }, + "eventLoop": "서버 이벤트 루프 사용률", + "eventLoopDescription": "서버 시작 이후 누적 활성 비율입니다. CPU 사용률이나 현재 응답 지연을 뜻하지 않습니다.", + "errors": { + "owner": "이 서버의 소유자로 로그인해야 진단을 볼 수 있습니다. 로컬 전용 접근은 서버에서 직접 ChatMux를 여세요.", + "unavailable": "진단 정보를 읽지 못했습니다. 연결 상태를 확인하고 요약을 새로고침하세요. 계속되면 해당 서버에서 chatmux status를 실행하세요." + }, + "recovery": { + "waiting": "세션 목록을 열어 일반 탐색을 사용한 뒤 돌아와 요약을 새로고침하세요. 아직 사용하지 않은 수집기에는 관측 정보가 없을 수 있습니다.", + "discovery": "세션 목록이 다시 연결되는지 확인한 뒤 요약을 새로고침하세요. 탐색 지연이나 실패가 계속되면 서버에서 chatmux status와 로컬 서비스 로그를 확인하세요. 기존 tmux 작업은 유지하세요.", + "watchLimit": "감시기가 시스템 감시 한도 문제를 보고했습니다. 호스트 관리자에게 inotify 감시 수·인스턴스 한도와 다른 감시 프로세스를 확인하도록 요청하세요. 반복 재시작으로 한도 소진이 해결되지는 않습니다.", + "watcher": "서버에서 GJC 설치와 ChatMux 서비스 로그를 확인하세요. 재시도 일정은 감시기가 관리하며 이 화면에서 재시작하지 않습니다. 기존 tmux 세션은 계속 실행할 수 있습니다.", + "terminal": "대화 기록을 검증할 수 없으면 ChatMux의 검증된 터미널 연결을 사용하세요. 연결이 거부되면 기존 터미널에서 정확한 tmux pane을 확인한 뒤 입력하세요." + }, + "reasons": { + "agent_user_mismatch": { + "title": "에이전트 사용자 불일치", + "guidance": "ChatMux와 에이전트가 의도한 OS 사용자로 실행되는지 확인하세요. 신원을 검증할 때까지 에이전트의 기존 터미널을 사용하세요." + }, + "agent_home_mismatch": { + "title": "에이전트 홈 불일치", + "guidance": "서비스 계정과 홈 설정이 에이전트 환경에 맞는지 확인하세요. 불일치를 우회하려고 자격 증명이나 대화 기록을 다른 계정에 복사하지 마세요." + }, + "agent_context_unreadable": { + "title": "에이전트 실행 정보 접근 불가", + "guidance": "호스트의 프로세스 조회 권한을 확인하세요. 신원 근거를 확인할 수 없는 동안 기존 터미널에서 세션을 사용하세요." + }, + "tmux_socket_owner_mismatch": { + "title": "tmux 소유자 불일치", + "guidance": "tmux 서버 소유 계정을 확인하세요. 소켓 권한을 완화하는 대신 의도한 계정으로 연결하세요." + }, + "tmux_pane_ambiguous": { + "title": "tmux pane 식별 불확실", + "guidance": "기존 터미널에서 대상 pane을 직접 확인하세요. 작업 디렉터리가 같다는 사실만으로 안전한 대상을 선택할 수는 없습니다." + }, + "transcript_ambiguous": { + "title": "대화 기록 연결 불확실", + "guidance": "제공자 세션 연결이 불확실하면 검증된 터미널 연결을 사용하세요. 디렉터리만으로 대화 기록을 선택하지 마세요." + }, + "transcript_permission_denied": { + "title": "대화 기록 접근 거부", + "guidance": "서비스 계정의 제공자 저장소 접근 권한을 서버에서 확인하세요. 비공개 기록을 공개하지 말고 검증된 터미널 연결을 사용하세요." + } + } + }, "title": "설정", "tabs": { "account": "계정",