diff --git a/main.js b/main.js index 4b94ed0e..cf1b6b02 100644 --- a/main.js +++ b/main.js @@ -488,16 +488,26 @@ const remoteAttachAdapter = createTmuxAttachAdapter({ // renderer can route a click without ever naming an attach mechanism itself // — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach"). function annotateRemoteAttachable(projects) { - const descriptorsByAlias = new Map(); + const hostInfoByAlias = new Map(); + function hostInfo(alias) { + if (!hostInfoByAlias.has(alias)) { + const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias); + hostInfoByAlias.set(alias, { at, error, byId: new Map(sessions.map(d => [d.sessionId, d])) }); + } + return hostInfoByAlias.get(alias); + } for (const project of projects) { + if (project.remoteAlias) { + const info = hostInfo(project.remoteAlias); + project.remoteHostAt = info.at; + project.remoteHostError = info.error; + } for (const session of project.sessions) { if (!session.remoteAlias) continue; - if (!descriptorsByAlias.has(session.remoteAlias)) { - const descriptors = remoteIndexer.getRemoteSessions(session.remoteAlias); - descriptorsByAlias.set(session.remoteAlias, new Map(descriptors.map(d => [d.sessionId, d]))); - } - const descriptor = descriptorsByAlias.get(session.remoteAlias).get(session.sessionId); + const descriptor = hostInfo(session.remoteAlias).byId.get(session.sessionId); session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + session.remoteStatus = descriptor ? (descriptor.status || null) : null; + session.remoteStatusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; } } return projects; @@ -2013,7 +2023,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se try { cachedFolder = getCachedFolder(sessionId); } catch {} if (isRemoteFolder(cachedFolder)) { const { alias } = parseFolderKey(cachedFolder); - const descriptor = remoteIndexer.getRemoteSessions(alias).find(s => s.sessionId === sessionId); + const descriptor = remoteIndexer.getRemoteSessions(alias).sessions.find(s => s.sessionId === sessionId); const localPtySize = normalizePtySize(initialSize); const attachResult = descriptor ? await remoteAttachAdapter.attach(alias, descriptor, localPtySize) diff --git a/public/sidebar.js b/public/sidebar.js index eb1f619e..0ffee734 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -17,6 +17,45 @@ function folderId(projectPath) { return 'project-' + projectPath.replace(/[^a-zA-Z0-9_-]/g, '_'); } +// see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") +function formatRemoteAge(epochMs) { + if (!Number.isFinite(epochMs)) return null; + const deltaMs = Date.now() - epochMs; + const s = Math.max(0, Math.floor(deltaMs / 1000)); + if (s < 60) return s + 's ago'; + const m = Math.floor(s / 60); + if (m < 60) return m + 'm ago'; + const h = Math.floor(m / 60); + if (h < 24) return h + 'h ago'; + const d = Math.floor(h / 24); + return d + 'd ago'; +} + +// Three states a remote host's project header can carry (issue #212): the last +// sync cycle failed (host unreachable, reason visible), the host has never +// been read yet, or it was read successfully and genuinely has no live +// session right now. See .ai/contexts/session-cache.md. +function remoteHostState(project) { + if (project.remoteHostError) { + const age = formatRemoteAge(project.remoteHostAt); + return { + cls: 'remote-host-error', + detail: 'host unreachable: ' + project.remoteHostError + + (age ? ' (last confirmed ' + age + ')' : ', never confirmed'), + }; + } + if (!Number.isFinite(project.remoteHostAt)) { + return { cls: 'remote-host-unknown', detail: 'not yet synced with this host' }; + } + const age = formatRemoteAge(project.remoteHostAt); + const liveCount = (project.sessions || []).filter(s => s.remoteStatus).length; + return { + cls: liveCount > 0 ? 'remote-host-live' : 'remote-host-empty', + detail: (liveCount > 0 ? liveCount + ' live session' + (liveCount > 1 ? 's' : '') : 'no live session') + + ' (confirmed ' + age + ')', + }; +} + // --- Subagent localStorage helpers --- // One-time GC: prune sessionIds that no longer exist in sessionMap. @@ -730,6 +769,15 @@ function renderProjects(projects, resort) { const missingIcon = project.missing ? ' ' : ''; header.innerHTML = ` ${missingIcon}${escapeHtml(shortName)}`; + // see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") + if (project.remoteAlias) { + const state = remoteHostState(project); + const hostDot = document.createElement('span'); + hostDot.className = 'session-status-dot remote-host-dot ' + state.cls; + hostDot.title = state.detail; + header.querySelector('.project-name').after(hostDot); + } + const scheduleBtn = document.createElement('button'); scheduleBtn.className = 'project-schedule-btn'; scheduleBtn.title = 'Create scheduled task'; @@ -1276,9 +1324,21 @@ function buildSessionItem(session) { if (session.remoteAlias) { const badge = document.createElement('span'); badge.className = 'remote-badge'; - badge.title = 'Read-only session mirrored from ' + session.remoteAlias; + badge.title = session.remoteAttachable + ? 'Live session on ' + session.remoteAlias + ' — click to attach' + : 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript'; badge.textContent = session.remoteAlias; summaryEl.prepend(badge); + + // status is the descriptor's last recorded transition, not a heartbeat — + // see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") + if (session.remoteStatus) { + const age = formatRemoteAge(session.remoteStatusUpdatedAt); + const statusEl = document.createElement('span'); + statusEl.className = 'session-remote-status'; + statusEl.textContent = session.remoteStatus + (age ? ' · ' + age : ''); + metaEl.appendChild(statusEl); + } } if (session.type === 'terminal') { diff --git a/public/style.css b/public/style.css index f19057c8..d07b5167 100644 --- a/public/style.css +++ b/public/style.css @@ -4150,6 +4150,22 @@ body { display: flex; flex-direction: column; } white-space: nowrap; } +/* Issue #212 — see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") */ +.session-remote-status { + color: #7fb3e0; +} + +.remote-host-dot { + display: inline-block; + margin: 0 0 0 4px; + vertical-align: middle; +} + +.remote-host-dot.remote-host-error { background: #e05a5a; } +.remote-host-dot.remote-host-unknown { background: #f5be3c; } +.remote-host-dot.remote-host-empty { background: rgba(255,255,255,0.18); } +.remote-host-dot.remote-host-live { background: #3ecf5a; } + .remote-hosts-list { margin: 8px 0; } diff --git a/remote-index.js b/remote-index.js index 1e48e613..1c3ac246 100644 --- a/remote-index.js +++ b/remote-index.js @@ -45,6 +45,7 @@ function createRemoteIndexer(ctx) { let inFlight = false; let stopped = false; const remoteSessions = new Map(); // alias -> sessions array, from the same ssh cycle as the inventory + const remoteSessionsAt = new Map(); // alias -> epoch ms of the last cycle that did not throw const hostBackoff = new Map(); // alias -> { failures, lastError, nextAttemptAt } function backoffState(alias) { @@ -116,6 +117,9 @@ function createRemoteIndexer(ctx) { for (const alias of [...remoteSessions.keys()]) { if (!known.has(alias)) remoteSessions.delete(alias); } + for (const alias of [...remoteSessionsAt.keys()]) { + if (!known.has(alias)) remoteSessionsAt.delete(alias); + } for (const alias of [...hostBackoff.keys()]) { if (!known.has(alias)) hostBackoff.delete(alias); } @@ -212,6 +216,7 @@ function createRemoteIndexer(ctx) { try { if (await refreshHost(host)) changed = true; onHostSuccess(host.alias); + remoteSessionsAt.set(host.alias, now()); } catch (err) { remoteSessions.set(host.alias, []); errors.push({ alias: host.alias, error: err.message }); @@ -260,8 +265,14 @@ function createRemoteIndexer(ctx) { return start(); } + // Freshness contract (issue #212) — see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") function getRemoteSessions(alias) { - return remoteSessions.get(alias) || []; + const backoff = hostBackoff.get(alias); + return { + sessions: remoteSessions.get(alias) || [], + at: remoteSessionsAt.get(alias) || null, + error: backoff ? backoff.lastError : null, + }; } return { diff --git a/test/dom-sidebar-remote-freshness.test.js b/test/dom-sidebar-remote-freshness.test.js new file mode 100644 index 00000000..aa0fe1a6 --- /dev/null +++ b/test/dom-sidebar-remote-freshness.test.js @@ -0,0 +1,200 @@ +// Issue #212 — getRemoteSessions(alias) used to render an empty array for +// three situations nothing distinguished: the host has no live session, the +// host was never read, or the last cycle failed (descriptors deliberately +// cleared). These tests pin that the sidebar now shows three distinct states, +// and that a stale status age never reads as fresh. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function remoteProject(overrides) { + return makeSampleProject({ + sessions: [], + ...overrides, + }); +} + +function hostDot(ctx, projectPath) { + const fId = ctx.sidebar.folderId(projectPath); + const header = ctx.document.getElementById('ph-' + fId); + assert.ok(header, 'project header must render for ' + projectPath); + return header.querySelector('.remote-host-dot'); +} + +test('a host whose last cycle failed shows an unknown state distinct from a genuinely empty host, reason visible', () => { + const ctx = setupSidebarDom(); + try { + const failedHost = remoteProject({ + projectPath: '/srv/failed-host', + folder: 'planificator::-srv-failed-host', + remoteAlias: 'planificator', + remoteHostAt: Date.parse('2026-09-08T10:00:00Z'), + remoteHostError: 'ssh: connect to host planificator port 22: timed out', + }); + const emptyHost = remoteProject({ + projectPath: '/srv/empty-host', + folder: 'planificator::-srv-empty-host', + remoteAlias: 'planificator', + remoteHostAt: Date.parse('2026-09-09T09:00:00Z'), + remoteHostError: null, + }); + + ctx.sidebar.renderProjects([failedHost, emptyHost], true); + + const failedDot = hostDot(ctx, '/srv/failed-host'); + const emptyDot = hostDot(ctx, '/srv/empty-host'); + + assert.ok(failedDot, 'a failed-cycle host must carry a status dot'); + assert.ok(emptyDot, 'a genuinely empty host must carry a status dot'); + assert.ok(failedDot.classList.contains('remote-host-error')); + assert.ok(emptyDot.classList.contains('remote-host-empty')); + assert.notEqual(failedDot.className, emptyDot.className, 'the two states must render as visually distinct'); + + assert.match(failedDot.title, /host unreachable/i, 'the reason must be visible, not just "empty"'); + assert.match(failedDot.title, /timed out/, 'the actual ssh failure reason must reach the UI'); + assert.doesNotMatch(emptyDot.title, /unreachable/i, 'a genuinely empty host must not be worded like a failure'); + } finally { ctx.destroy(); } +}); + +test('a host never yet read is distinct from both a failed host and an empty host', () => { + const ctx = setupSidebarDom(); + try { + const neverRead = remoteProject({ + projectPath: '/srv/never-read', + folder: 'planificator::-srv-never-read', + remoteAlias: 'planificator', + // remoteHostAt / remoteHostError intentionally absent: annotateRemoteAttachable + // never ran (or the alias was never refreshed) — this is the "never read" case. + }); + const failedHost = remoteProject({ + projectPath: '/srv/failed-host-2', + folder: 'planificator::-srv-failed-host-2', + remoteAlias: 'planificator', + remoteHostAt: Date.parse('2026-09-08T10:00:00Z'), + remoteHostError: 'ssh: connect to host planificator port 22: timed out', + }); + const emptyHost = remoteProject({ + projectPath: '/srv/empty-host-2', + folder: 'planificator::-srv-empty-host-2', + remoteAlias: 'planificator', + remoteHostAt: Date.parse('2026-09-09T09:00:00Z'), + remoteHostError: null, + }); + + ctx.sidebar.renderProjects([neverRead, failedHost, emptyHost], true); + + const neverReadDot = hostDot(ctx, '/srv/never-read'); + const failedDot = hostDot(ctx, '/srv/failed-host-2'); + const emptyDot = hostDot(ctx, '/srv/empty-host-2'); + + assert.ok(neverReadDot.classList.contains('remote-host-unknown')); + assert.match(neverReadDot.title, /not yet synced/i); + + const classes = new Set([neverReadDot.className, failedDot.className, emptyDot.className]); + assert.equal(classes.size, 3, 'never-read, failed and empty must be three visually distinct states'); + } finally { ctx.destroy(); } +}); + +test('a live remote session shows its status and age, and a 24h-old status does not read as fresh', () => { + const ctx = setupSidebarDom(); + try { + const now = Date.parse('2026-09-09T12:00:00Z'); + const realNow = ctx.window.Date.now; + ctx.window.Date.now = () => now; + try { + const staleSession = { + sessionId: 'remote-stale', + summary: 'stale one', + modified: '2026-09-08T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + projectPath: '/srv/live-host', + remoteAlias: 'planificator', + remoteStatus: 'idle', + remoteStatusUpdatedAt: now - 23 * 3600 * 1000, // 23h ago + }; + const freshSession = { + sessionId: 'remote-fresh', + summary: 'fresh one', + modified: '2026-09-09T11:59:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + projectPath: '/srv/live-host', + remoteAlias: 'planificator', + remoteStatus: 'idle', + remoteStatusUpdatedAt: now - 26 * 1000, // 26s ago + }; + const project = remoteProject({ + projectPath: '/srv/live-host', + folder: 'planificator::-srv-live-host', + remoteAlias: 'planificator', + remoteHostAt: now, + remoteHostError: null, + sessions: [staleSession, freshSession], + }); + + ctx.sidebar.renderProjects([project], true); + + const staleEl = ctx.document.getElementById('si-remote-stale').querySelector('.session-remote-status'); + const freshEl = ctx.document.getElementById('si-remote-fresh').querySelector('.session-remote-status'); + assert.ok(staleEl, 'the stale session must show a status/age indicator'); + assert.ok(freshEl, 'the fresh session must show a status/age indicator'); + + assert.match(freshEl.textContent, /idle.*26s ago/); + assert.match(staleEl.textContent, /idle.*23h ago/); + assert.notEqual(staleEl.textContent, freshEl.textContent); + assert.doesNotMatch(staleEl.textContent, /\ds ago/, 'a 24h-old status must not read as a fresh few-seconds-old one'); + } finally { + ctx.window.Date.now = realNow; + } + } finally { ctx.destroy(); } +}); + +test('a host genuinely without any live session is distinct from a host with a live one', () => { + const ctx = setupSidebarDom(); + try { + const liveSession = { + sessionId: 'remote-live', + summary: 'live one', + modified: '2026-09-09T11:59:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + projectPath: '/srv/live-project', + remoteAlias: 'planificator', + remoteStatus: 'busy', + remoteStatusUpdatedAt: Date.now() - 5000, + }; + const liveProject = remoteProject({ + projectPath: '/srv/live-project', + folder: 'planificator::-srv-live-project', + remoteAlias: 'planificator', + remoteHostAt: Date.now(), + remoteHostError: null, + sessions: [liveSession], + }); + const emptyProject = remoteProject({ + projectPath: '/srv/really-empty', + folder: 'planificator::-srv-really-empty', + remoteAlias: 'planificator', + remoteHostAt: Date.now(), + remoteHostError: null, + sessions: [], + }); + + ctx.sidebar.renderProjects([liveProject, emptyProject], true); + + const liveDot = hostDot(ctx, '/srv/live-project'); + const emptyDot = hostDot(ctx, '/srv/really-empty'); + + assert.ok(liveDot.classList.contains('remote-host-live')); + assert.ok(emptyDot.classList.contains('remote-host-empty')); + assert.notEqual(liveDot.className, emptyDot.className); + assert.match(liveDot.title, /1 live session/); + assert.match(emptyDot.title, /no live session/i); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-sidebar-remote-session.test.js b/test/dom-sidebar-remote-session.test.js index 85073e5e..76ee219e 100644 --- a/test/dom-sidebar-remote-session.test.js +++ b/test/dom-sidebar-remote-session.test.js @@ -64,6 +64,9 @@ test('a remote session with no live attachable descriptor opens the transcript, assert.deepEqual(viewed, ['remote-1']); assert.deepEqual(opened, [], 'openSession would spawn a PTY in a cwd that is not on this machine'); assert.match(item.title, /not currently attachable/i, 'the row must say why it fell back to the transcript'); + const badge = item.querySelector('.remote-badge'); + assert.match(badge.title, /transcript/i, 'the badge must not promise an attach it will not perform'); + assert.doesNotMatch(badge.title, /read-only session mirrored/i, 'the pre-attach wording is obsolete'); assert.doesNotMatch(item.title, /tmux/i, 'the renderer must never name a multiplexer'); } finally { ctx.destroy(); } }); @@ -91,6 +94,9 @@ test('a remote session with a live attachable descriptor opens a terminal, not t assert.deepEqual(opened, ['remote-2']); assert.deepEqual(viewed, [], 'an attachable remote session must open a terminal, not the read-only transcript'); assert.ok(!item.title, 'an attachable session carries no fallback-reason title'); + const badge = item.querySelector('.remote-badge'); + assert.match(badge.title, /attach/i, 'an attachable session must not be described as read-only'); + assert.doesNotMatch(badge.title, /read-only/i, 'an attachable session must not be described as read-only'); } finally { ctx.destroy(); } }); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index 2676f4e2..ef2b564d 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -171,10 +171,18 @@ test('getRemoteSessions surfaces per-host session descriptors from the same sync const r = await indexer.refreshNow(); assert.deepEqual(r.errors, [], 'both hosts complete without error'); - assert.deepEqual(indexer.getRemoteSessions('withSessions'), sessionsByAlias.withSessions); - assert.deepEqual(indexer.getRemoteSessions('empty'), []); - assert.deepEqual(indexer.getRemoteSessions('some-alias-never-refreshed'), [], - 'an unknown alias must never throw or return undefined'); + const withSessions = indexer.getRemoteSessions('withSessions'); + assert.deepEqual(withSessions.sessions, sessionsByAlias.withSessions); + assert.ok(Number.isInteger(withSessions.at), 'a successful cycle records when it happened'); + assert.equal(withSessions.error, null); + const empty = indexer.getRemoteSessions('empty'); + assert.deepEqual(empty.sessions, []); + assert.ok(Number.isInteger(empty.at), 'a host with zero live sessions still had a successful read'); + assert.equal(empty.error, null); + const neverRefreshed = indexer.getRemoteSessions('some-alias-never-refreshed'); + assert.deepEqual(neverRefreshed.sessions, [], 'an unknown alias must never throw or return undefined'); + assert.equal(neverRefreshed.at, null, 'an alias never refreshed has no successful-read timestamp'); + assert.equal(neverRefreshed.error, null); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); @@ -203,12 +211,20 @@ test('getRemoteSessions is cleared, not left stale, after a cycle where sync() t const r1 = await indexer.refreshNow(); assert.deepEqual(r1.errors, []); - assert.deepEqual(indexer.getRemoteSessions('planificator'), [{ pid: 1, sessionId: 'still-alive' }]); + const afterSuccess = indexer.getRemoteSessions('planificator'); + assert.deepEqual(afterSuccess.sessions, [{ pid: 1, sessionId: 'still-alive' }]); + assert.ok(Number.isInteger(afterSuccess.at)); + assert.equal(afterSuccess.error, null); const r2 = await indexer.refreshNow(); assert.equal(r2.errors.length, 1, 'the second cycle must be reported as failed'); - assert.deepEqual(indexer.getRemoteSessions('planificator'), [], + const afterFailure = indexer.getRemoteSessions('planificator'); + assert.deepEqual(afterFailure.sessions, [], 'a failed cycle must not keep reporting hours-old sessions as live'); + assert.match(afterFailure.error, /timed out/, + 'the failure reason must survive on the accessor so the UI can distinguish it from a genuinely idle host'); + assert.equal(afterFailure.at, afterSuccess.at, + 'the last-successful-read timestamp is not a heartbeat: a failed cycle does not bump it'); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } });