diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 67d19593..4e4f168c 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -758,6 +758,29 @@ created the `.jsonl`; a manual host refresh did not help. rel path, not its own, because `readSubagentMeta()` in the transcript's row is what actually needs re-deriving. +### Remote hosts — capability tiers (issue #218, first slice) + +`remote-host-profile.js` is a pure function: `computeHostProfile({ at, error, descriptors })` returns +`{ tier, tiers, missing }`, the highest of `observe < liveness < inject < attach < launch` that is +available plus, for every tier above it, the reason it is not. The indexer's `getRemoteHostProfile(alias)` +feeds it the last cycle's own data (`at`, `error`, live descriptors), so there is no probe and no extra ssh. + +- `none`: never synced, or the last cycle failed. A failed `find ~/.claude/projects` fails the whole + cycle, so an unreadable projects directory and an unreachable host are not told apart; the ssh error is the reason. +- `liveness`: at least one live descriptor. `inject`: a live descriptor with a `messagingSocketPath` that is a POSIX + absolute path. `attach`: a live descriptor naming a tmux pane with a valid pid (the adapter's own test). + The tiers are independent requirements: the reported tier is the highest available one, not the highest contiguous one. +- `launch` is never available: starting a session from here is not implemented. +- A tier that needs a live session reads as missing on an idle host; that is "nothing to read it from", not "unsupported". +- `annotateRemoteAttachable` (main.js) puts the profile on the project (`remoteHostProfile`). After 3 consecutive failed + cycles (`attachBlockReason`), it sets `remoteAttachable: false` plus `remoteAttachBlocked` (the last error) on the + session: a single transient poll failure blocks nothing, and the descriptors of the last good cycle are kept. + Stop is never blocked, it runs its own ssh. The renderer only shows the strings: the host dot's tooltip (which states + the last error from the first failure), the row and badge titles. +- The new-session button was already disabled for every remote host; it is unchanged. +- Not done: the probe for what the descriptors cannot tell (multiplexer installed but no session in it, `inotifywait`), + the inject affordance (issue #219), the launch tier. + ## Remote hosts — sending a prompt (issue #219) `remote-send.js` writes one prompt to a live, unattached remote session through diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5686a6..00da1499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### New +- A remote host's project header now shows what the host supports: hover its status dot to see the highest capability reached (observe, liveness, inject, attach) and, for each one above it, why it is missing. (#218) + +### Changed +- After three failed refreshes of a remote host in a row, a row that would have attached opens its transcript and says why in its tooltip, instead of failing when clicked. Stop is never disabled: it runs its own ssh. (#218) ### Fixed - A step of a trigger chain that follows `/compact` now waits for the CLI to be back at its prompt before it is written, and a step whose Enter did not start a turn is retried once and then reported as "not confirmed submitted" in the log and the result instead of "sent". (#407) - Stopping a terminal twice in quick succession, or resizing it while it is being stopped, no longer closes the Windows pseudo console twice, which could kill the whole app with no error. (#405) diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index bc25acbf..0efae426 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -120,6 +120,13 @@ The project header carries a dot for the host's state, with a tooltip: | red | *host unreachable: <error>*, when it was last confirmed, and when the next attempt is | | amber, pulsing | a reconnect in progress | +Hovering the dot also lists the host's capability: the highest of observe, +liveness, inject, attach and launch that its last refresh could confirm, and for +each one above it why it is missing (for example no live session names a tmux +pane). A tier that needs a live session reads as missing on an idle host. After +three failed refreshes in a row, a row that would attach opens its transcript, +with the reason in its tooltip; **Stop** is never disabled, it runs its own ssh. + A failing host is retried with a doubling delay, up to 30 minutes, and never dropped; one success resets it. **Reconnect** on the header retries at once and restarts the watch connection. The sidebar's global refresh retries every host. diff --git a/main.js b/main.js index 2935eb04..2a7ea9f4 100644 --- a/main.js +++ b/main.js @@ -82,6 +82,7 @@ const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); const { createTmuxAttachAdapter } = require('./remote-attach'); const { createRemoteStopAdapter } = require('./remote-stop'); +const { attachBlockReason } = require('./remote-host-profile'); const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send'); const { createGitChangesRunner, localGitEnv } = require('./git-changes-runner'); const { runToExit } = require('./run-to-exit'); @@ -569,8 +570,9 @@ function annotateRemoteAttachable(projects) { function hostInfo(alias) { if (!hostInfoByAlias.has(alias)) { const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias); - const { nextAttemptAt } = remoteIndexer.getRemoteHostState(alias); - hostInfoByAlias.set(alias, { at, error, nextAttemptAt, byId: new Map(sessions.map(d => [d.sessionId, d])) }); + const { nextAttemptAt, consecutiveFailures } = remoteIndexer.getRemoteHostState(alias); + const profile = remoteIndexer.getRemoteHostProfile(alias); + hostInfoByAlias.set(alias, { at, error, nextAttemptAt, consecutiveFailures, profile, byId: new Map(sessions.map(d => [d.sessionId, d])) }); } return hostInfoByAlias.get(alias); } @@ -580,11 +582,16 @@ function annotateRemoteAttachable(projects) { project.remoteHostAt = info.at; project.remoteHostError = info.error; project.remoteHostNextAttemptAt = info.nextAttemptAt || null; + project.remoteHostProfile = info.profile; } for (const session of project.sessions) { if (session.remoteAlias) { - const descriptor = hostInfo(session.remoteAlias).byId.get(session.sessionId); - session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + const info = hostInfo(session.remoteAlias); + const descriptor = info.byId.get(session.sessionId); + const hostBlocked = attachBlockReason(info.profile, info.consecutiveFailures); + const supportsAttach = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + session.remoteAttachable = supportsAttach && !hostBlocked; + session.remoteAttachBlocked = supportsAttach ? hostBlocked : null; session.status = descriptor ? (descriptor.status || null) : null; session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; session.waitingFor = descriptor ? (descriptor.waitingFor || null) : null; diff --git a/public/sidebar.js b/public/sidebar.js index 8c696409..d5adc0f6 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -56,7 +56,19 @@ function formatNextAttemptIn(epochMs) { // 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 remoteHostTierLines(profile) { + if (!profile || typeof profile.tier !== 'string' || !Array.isArray(profile.missing)) return ''; + return '\nCapability: ' + profile.tier + + profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join(''); +} + function remoteHostState(project) { + const state = remoteHostStatus(project); + state.detail += remoteHostTierLines(project.remoteHostProfile); + return state; +} + +function remoteHostStatus(project) { if (project.remoteHostError) { const age = formatStatusAge(project.remoteHostAt); const nextIn = formatNextAttemptIn(project.remoteHostNextAttemptAt); @@ -1233,7 +1245,9 @@ function rebindSidebarEvents(projects) { // see .ai/contexts/session-cache.md ("Remote SSH hosts") if (session.remoteAlias && !session.remoteAttachable) { - item.title = 'Not currently attachable — opening the transcript instead'; + item.title = session.remoteAttachBlocked + ? 'Attach unavailable: ' + session.remoteAttachBlocked + ' — opening the transcript instead' + : 'Not currently attachable — opening the transcript instead'; } item.onclick = () => { @@ -1472,7 +1486,9 @@ function buildSessionItem(session) { badge.className = 'remote-badge'; badge.title = session.remoteAttachable ? 'Live session on ' + session.remoteAlias + ' — click to attach' - : 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript'; + : session.remoteAttachBlocked + ? 'Session on ' + session.remoteAlias + ' — attach unavailable: ' + session.remoteAttachBlocked + ', click to read its transcript' + : 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript'; badge.textContent = session.remoteAlias; summaryEl.prepend(badge); } diff --git a/remote-host-profile.js b/remote-host-profile.js new file mode 100644 index 00000000..dba99fc2 --- /dev/null +++ b/remote-host-profile.js @@ -0,0 +1,65 @@ +// see .ai/contexts/session-cache.md ("Remote hosts — capability tiers") +'use strict'; + +const { parseTmuxField, isValidPid } = require('./remote-attach'); + +const ATTACH_BLOCK_AFTER_FAILURES = 3; +const TIERS = ['observe', 'liveness', 'inject', 'attach', 'launch']; + +function hasPosixSocketPath(descriptor) { + const p = descriptor && descriptor.messagingSocketPath; + return typeof p === 'string' && p.startsWith('/') && !p.includes('\0') && !p.includes('\\'); +} + +function namesTmuxPane(descriptor) { + return !!(descriptor && parseTmuxField(descriptor.tmux) && isValidPid(descriptor.pid)); +} + +/** + * Highest capability tier of a host, from what its last refresh cycle knew. + * input: { at: epoch ms of the last successful cycle | null, + * error: last cycle's error message | null, + * descriptors: live session descriptors of that cycle } + * -> { tier: 'none' | a TIERS name, + * tiers: [{ tier, available, reason }], + * missing: [{ tier, reason }] for every tier above `tier` } + */ +function computeHostProfile(input) { + const { at, error } = input || {}; + const descriptors = Array.isArray(input && input.descriptors) ? input.descriptors.filter(d => d && typeof d === 'object') : []; + + let blocked = null; + if (error) blocked = `last refresh of this host failed: ${error}`; + else if (!Number.isFinite(at)) blocked = 'not yet synced with this host'; + + const reasons = {}; + if (blocked) { + for (const tier of TIERS) reasons[tier] = blocked; + } else { + reasons.observe = null; + reasons.liveness = descriptors.length > 0 ? null + : 'no live session descriptor under ~/.claude/sessions on this host (one appears while a session runs)'; + reasons.inject = descriptors.some(hasPosixSocketPath) ? null + : 'no live session reports a messagingSocketPath on a POSIX path'; + reasons.attach = descriptors.some(namesTmuxPane) ? null + : 'no live session names a tmux pane in its descriptor (start it inside tmux)'; + reasons.launch = 'new sessions cannot be started from here; they must be started on the host'; + } + + const tiers = TIERS.map(tier => ({ tier, available: reasons[tier] === null, reason: reasons[tier] })); + let top = -1; + tiers.forEach((t, i) => { if (t.available) top = i; }); + return { + tier: top === -1 ? 'none' : TIERS[top], + tiers, + missing: tiers.slice(top + 1).map(t => ({ tier: t.tier, reason: t.reason })), + }; +} + +function attachBlockReason(profile, consecutiveFailures) { + if (!(consecutiveFailures >= ATTACH_BLOCK_AFTER_FAILURES)) return null; + const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === 'observe'); + return hit && hit.reason ? hit.reason : null; +} + +module.exports = { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS }; diff --git a/remote-index.js b/remote-index.js index a1c2da49..c3c6bacc 100644 --- a/remote-index.js +++ b/remote-index.js @@ -12,6 +12,7 @@ const { } = require('./remote-hosts'); const { syncMirror } = require('./remote-mirror'); const { encodeProjectPath } = require('./encode-project-path'); +const { computeHostProfile } = require('./remote-host-profile'); const NOOP_LOG = { info() {}, warn() {}, error() {} }; @@ -374,6 +375,12 @@ function createRemoteIndexer(ctx) { }; } + // see .ai/contexts/session-cache.md ("Remote hosts — capability tiers") + function getRemoteHostProfile(alias) { + const { sessions, at, error } = getRemoteSessions(alias); + return computeHostProfile({ at, error, descriptors: sessions }); + } + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") function getPlaceholderSessions(alias) { const list = remoteSessions.get(alias) || []; @@ -414,6 +421,7 @@ function createRemoteIndexer(ctx) { start, stop, dispose, restart, refreshNow, refreshHostNow, isRunning: () => timer !== null, getRemoteSessions, + getRemoteHostProfile, getPlaceholderSessions, getAllPlaceholderSessions, findSessionAlias, diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index 3e71dea8..80a84f19 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -15,6 +15,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { computeHostProfile, attachBlockReason } = require('../remote-host-profile'); + const root = path.join(__dirname, '..'); function extractAnnotateRemoteAttachableSource() { @@ -36,16 +38,19 @@ function makeAnnotate(mocks) { const source = extractAnnotateRemoteAttachableSource(); const factory = new Function( 'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState', + 'attachBlockReason', source + '\nreturn annotateRemoteAttachable;' ); return factory( mocks.remoteIndexer || { getRemoteSessions: () => ({ sessions: [], at: null, error: null }), getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: null, error: null, descriptors: [] }), }, mocks.remoteAttachAdapter || { supports: () => false }, mocks.remoteActivityTracker || { activeAt: () => null }, - mocks.cliSessionState || { getStatus: () => undefined } + mocks.cliSessionState || { getStatus: () => undefined }, + attachBlockReason ); } @@ -94,6 +99,7 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip error: null, }), getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 111, error: null, descriptors: [{ pid: 4, sessionId: 'remote-1' }] }), }, remoteAttachAdapter: { supports: () => true }, cliSessionState: { getStatus: () => { throw new Error('must not be called for a remote session'); } }, @@ -112,6 +118,66 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip assert.equal(projects[0].sessions[0].remoteAttachable, true); }); +function failedHostIndexer(error, failures = 3) { + return { + getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error }), + getRemoteHostState: () => ({ consecutiveFailures: failures, lastError: error, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 111, error, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }), + }; +} + +function remoteProjects() { + return [{ + projectPath: '/srv/proj', + remoteAlias: 'planificator', + sessions: [{ sessionId: 'remote-1', remoteAlias: 'planificator' }], + }]; +} + +test('a session on a host that failed three refreshes in a row is not attachable and carries the reason', () => { + const annotate = makeAnnotate({ + remoteIndexer: failedHostIndexer('connect timed out'), + remoteAttachAdapter: { supports: () => true }, + }); + const projects = remoteProjects(); + annotate(projects); + const session = projects[0].sessions[0]; + assert.equal(session.remoteAttachable, false); + assert.match(session.remoteAttachBlocked, /connect timed out/); + assert.equal(session.remoteStopBlocked, undefined, 'stop runs its own ssh and is never blocked by a poll failure'); + assert.equal(projects[0].remoteHostProfile.tier, 'none'); +}); + +test('a single failed refresh does not block attach, while the project profile still states the error', () => { + const annotate = makeAnnotate({ + remoteIndexer: failedHostIndexer('connect timed out', 1), + remoteAttachAdapter: { supports: () => true }, + }); + const projects = remoteProjects(); + annotate(projects); + const session = projects[0].sessions[0]; + assert.equal(session.remoteAttachable, true); + assert.equal(session.remoteAttachBlocked, null); + assert.match(projects[0].remoteHostProfile.missing[0].reason, /connect timed out/); +}); + +test('a session on a healthy host carries no blocking reason and the project carries the host profile', () => { + const annotate = makeAnnotate({ + remoteIndexer: { + getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error: null }), + getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 111, error: null, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }), + }, + remoteAttachAdapter: { supports: () => true }, + }); + const projects = remoteProjects(); + annotate(projects); + const session = projects[0].sessions[0]; + assert.equal(session.remoteAttachable, true); + assert.equal(session.remoteAttachBlocked, null); + assert.equal(projects[0].remoteHostProfile.tier, 'attach'); +}); + test('a remote session carries the descriptor waitingFor, and null when the descriptor has none or is gone', () => { const annotateRemoteAttachable = makeAnnotate({ remoteIndexer: { @@ -124,6 +190,7 @@ test('a remote session carries the descriptor waitingFor, and null when the desc error: null, }), getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 1, error: null, descriptors: [] }), }, }); const projects = [{ diff --git a/test/dom-sidebar-remote-host-tier.test.js b/test/dom-sidebar-remote-host-tier.test.js new file mode 100644 index 00000000..04dbb52f --- /dev/null +++ b/test/dom-sidebar-remote-host-tier.test.js @@ -0,0 +1,76 @@ +// Issue #218 — the host's capability tier is shown on its project header +// and every affordance above the tier is disabled with the reason as title. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); +const { computeHostProfile } = require('../remote-host-profile'); + +const SESSION = { + sessionId: 'remote-1', + summary: 'ripcord protocol', + modified: '2026-09-06T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 4, + projectPath: '/srv/supervision', + remoteAlias: 'planificator', + remoteDescriptorSeen: true, +}; + +function project(profile, session) { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + remoteHostAt: Date.parse('2026-10-01T10:00:00Z'), + remoteHostError: null, + remoteHostProfile: profile, + sessions: [session], + }); +} + +function header(ctx) { + return ctx.document.getElementById('ph-' + ctx.sidebar.folderId('/srv/supervision')); +} + +test('the host dot title names the tier and the reason of every tier above it', () => { + const ctx = setupSidebarDom(); + try { + const profile = computeHostProfile({ at: Date.now(), error: null, descriptors: [{ pid: 5, sessionId: 'a' }] }); + ctx.sidebar.renderProjects([project(profile, SESSION)], true); + const title = header(ctx).querySelector('.remote-host-dot').title; + assert.match(title, /Capability: liveness/); + assert.match(title, /inject unavailable: no live session reports a messagingSocketPath/); + assert.match(title, /attach unavailable: no live session names a tmux pane/); + assert.match(title, /launch unavailable/); + } finally { ctx.destroy(); } +}); + +test('a host with no profile keeps the plain status title', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([project(undefined, SESSION)], true); + assert.doesNotMatch(header(ctx).querySelector('.remote-host-dot').title, /Capability/); + } finally { ctx.destroy(); } +}); + +test('a row whose attach is blocked says why in its title and badge, and opens the transcript', () => { + const ctx = setupSidebarDom(); + try { + const blocked = { ...SESSION, remoteAttachable: false, remoteAttachBlocked: 'last refresh of this host failed: connect timed out' }; + ctx.window.sessionMap.set(blocked.sessionId, blocked); + ctx.sidebar.renderProjects([project(undefined, blocked)], true); + const viewed = []; + const opened = []; + ctx.window.showJsonlViewer = (s) => viewed.push(s.sessionId); + ctx.window.openSession = (s) => opened.push(s.sessionId); + const item = ctx.document.getElementById('si-remote-1'); + assert.match(item.title, /connect timed out/); + assert.match(item.querySelector('.remote-badge').title, /connect timed out/); + item.onclick(); + assert.deepEqual(viewed, ['remote-1']); + assert.deepEqual(opened, []); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-host-profile.test.js b/test/remote-host-profile.test.js new file mode 100644 index 00000000..1eecbc25 --- /dev/null +++ b/test/remote-host-profile.test.js @@ -0,0 +1,100 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS } = require('../remote-host-profile'); + +const AT = Date.parse('2026-10-01T10:00:00Z'); +const tmuxDescriptor = { pid: 101, sessionId: 'a', tmux: 'main:@0.%0' }; +const socketDescriptor = { pid: 102, sessionId: 'b', messagingSocketPath: '/run/user/1000/claude-102.sock' }; + +function reasonFor(profile, tier) { + const hit = profile.missing.find(m => m.tier === tier); + return hit ? hit.reason : undefined; +} + +test('the tiers are ordered observe < liveness < inject < attach < launch', () => { + assert.deepEqual(TIERS, ['observe', 'liveness', 'inject', 'attach', 'launch']); +}); + +test('a host never synced has no tier and every tier says it was not synced', () => { + const profile = computeHostProfile({ at: null, error: null, descriptors: [] }); + assert.equal(profile.tier, 'none'); + assert.deepEqual(profile.missing.map(m => m.tier), TIERS); + assert.match(reasonFor(profile, 'observe'), /not yet synced/); +}); + +test('a host whose last cycle failed has no tier and the ssh error is the reason of every tier', () => { + const profile = computeHostProfile({ at: AT, error: 'connect timed out', descriptors: [tmuxDescriptor] }); + assert.equal(profile.tier, 'none'); + for (const tier of TIERS) assert.match(reasonFor(profile, tier), /connect timed out/); +}); + +test('a synced host with no live descriptor stops at observe and says why liveness is missing', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [] }); + assert.equal(profile.tier, 'observe'); + assert.deepEqual(profile.missing.map(m => m.tier), ['liveness', 'inject', 'attach', 'launch']); + assert.match(reasonFor(profile, 'liveness'), /no live session descriptor/); +}); + +test('a live descriptor without socket or multiplexer reaches liveness only', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [{ pid: 7, sessionId: 'x' }] }); + assert.equal(profile.tier, 'liveness'); + assert.match(reasonFor(profile, 'inject'), /messagingSocketPath/); + assert.match(reasonFor(profile, 'attach'), /tmux/); +}); + +test('a live descriptor naming a POSIX messagingSocketPath makes inject available', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [socketDescriptor] }); + assert.equal(profile.tier, 'inject'); + assert.deepEqual(profile.missing.map(m => m.tier), ['attach', 'launch']); +}); + +test('a messagingSocketPath that is not a POSIX absolute path does not count', () => { + for (const bad of ['\\\\.\\pipe\\claude-1', 'relative/sock', '', 42, null]) { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [{ pid: 9, sessionId: 'x', messagingSocketPath: bad }] }); + assert.equal(profile.tier, 'liveness', JSON.stringify(bad)); + } +}); + +test('a descriptor naming a tmux pane makes attach available even without a socket', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor] }); + assert.equal(profile.tier, 'attach'); + assert.deepEqual(profile.missing.map(m => m.tier), ['launch']); +}); + +test('the highest tier wins when several sessions bring different capabilities', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [socketDescriptor, tmuxDescriptor] }); + assert.equal(profile.tier, 'attach'); + assert.equal(profile.tiers.find(t => t.tier === 'inject').available, true); +}); + +test('a tmux descriptor with an invalid pid does not count', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [{ pid: 0, sessionId: 'x', tmux: 'main:@0.%0' }] }); + assert.equal(profile.tier, 'liveness'); +}); + +test('launch is never available in this build and says to start the session on the host', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor, socketDescriptor] }); + assert.match(reasonFor(profile, 'launch'), /started on the host/); +}); + +test('garbage input never throws and yields no tier', () => { + assert.equal(computeHostProfile(undefined).tier, 'none'); + assert.equal(computeHostProfile({ at: AT, error: null, descriptors: 'nope' }).tier, 'observe'); + assert.equal(computeHostProfile({ at: AT, error: null, descriptors: [null, 3] }).tier, 'observe'); +}); + +test('attach is blocked only from the third consecutive failure, with the last error as reason', () => { + const profile = computeHostProfile({ at: AT, error: 'connect timed out', descriptors: [tmuxDescriptor] }); + assert.equal(ATTACH_BLOCK_AFTER_FAILURES, 3); + assert.equal(attachBlockReason(profile, 1), null); + assert.equal(attachBlockReason(profile, 2), null); + assert.match(attachBlockReason(profile, 3), /connect timed out/); + assert.match(attachBlockReason(profile, 9), /connect timed out/); +}); + +test('attach is never blocked on a host with no error, whatever the failure count', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor] }); + assert.equal(attachBlockReason(profile, 5), null); +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index e87b14ff..da22a80a 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -889,6 +889,37 @@ test('refreshNow({force:true}) ignores backoff for every host and resets it on s } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); +test('getRemoteHostProfile follows the last cycle: never synced, then live tmux session, then a failure', async () => { + const dataDir = tmp('idx-profile'); + try { + const clock = fakeClock(1_000); + let outcome = 'tmux'; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'box' }], + getRefreshMs: () => 60_000, + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + now: clock, + sync: async () => { + if (outcome === 'fail') throw new Error('connect timed out'); + return { changedFolders: [], sessions: [{ pid: 5, sessionId: 's', tmux: 'main:@0.%0' }] }; + }, + }); + + assert.equal(indexer.getRemoteHostProfile('box').tier, 'none', 'never synced'); + await indexer.refreshNow(); + assert.equal(indexer.getRemoteHostProfile('box').tier, 'attach'); + outcome = 'fail'; + await indexer.refreshNow({ force: true }); + const failed = indexer.getRemoteHostProfile('box'); + assert.equal(failed.tier, 'none'); + assert.match(failed.missing[0].reason, /connect timed out/); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + test('sanitizeWaitingFor keeps a short plain string and drops everything else', () => { const { sanitizeWaitingFor } = require('../remote-index'); assert.equal(sanitizeWaitingFor('permission prompt'), 'permission prompt');