mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(remote): refresh wake command from host config when restoring sessions
A session's remote block is persisted at launch time and recovery uses that snapshot, so a wakeCommand added to remote-hosts.json afterwards never reached an already-running session - not even across a Codeman restart (observed: the live Hufflepuff session came back with no wakeCommand). Merge the host-level field in on restore, with the host config authoritative.
This commit is contained in:
@@ -540,6 +540,32 @@ export function remoteDisplayPath(
|
|||||||
return `${remote.username}@${remote.host}:${path}`;
|
return `${remote.username}@${remote.host}:${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh HOST-level config on a RESTORED `SessionRemote`.
|
||||||
|
*
|
||||||
|
* A session's `remote` block is persisted at launch time (mux-sessions.json /
|
||||||
|
* state.json) and recovery uses that snapshot, so a field ADDED to the host config
|
||||||
|
* later never reaches an already-running session — not even across a Codeman
|
||||||
|
* restart. That is exactly how a `wakeCommand` added to `remote-hosts.json` would
|
||||||
|
* silently do nothing until the session is relaunched (which for an owned remote
|
||||||
|
* session means killing the remote tmux).
|
||||||
|
*
|
||||||
|
* Deliberately narrow: ONLY `wakeCommand` is taken from the host config, and the
|
||||||
|
* host is authoritative for it (removing it in the config turns the feature off
|
||||||
|
* again). The other host-level fields (`commands`, ssh options) stay as persisted
|
||||||
|
* so this cannot silently change how an existing pane connects.
|
||||||
|
*/
|
||||||
|
export function rehydrateRemoteHostFields(
|
||||||
|
remote: SessionRemote | undefined,
|
||||||
|
hostsById: ReadonlyMap<string, RemoteHost>
|
||||||
|
): SessionRemote | undefined {
|
||||||
|
if (!remote) return remote;
|
||||||
|
const host = hostsById.get(remote.hostId);
|
||||||
|
if (!host) return remote;
|
||||||
|
if (remote.wakeCommand === host.wakeCommand) return remote;
|
||||||
|
return { ...remote, wakeCommand: host.wakeCommand };
|
||||||
|
}
|
||||||
|
|
||||||
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
|
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
|
||||||
return {
|
return {
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
|
|||||||
+7
-1
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { hostname as getHostname } from 'node:os';
|
import { hostname as getHostname } from 'node:os';
|
||||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||||
|
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
|
||||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||||
import { GLYPH, palette } from '../cli-style.js';
|
import { GLYPH, palette } from '../cli-style.js';
|
||||||
import { getHookSecret } from '../config/hook-secret.js';
|
import { getHookSecret } from '../config/hook-secret.js';
|
||||||
@@ -2867,6 +2868,9 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// For each alive mux session, create a Session object if it doesn't exist
|
// For each alive mux session, create a Session object if it doesn't exist
|
||||||
const muxSessions = this.mux.getSessions();
|
const muxSessions = this.mux.getSessions();
|
||||||
|
// Host-level config lives in remote-hosts.json, not in the persisted session
|
||||||
|
// snapshot, so refresh the fields that only exist there (see the helper).
|
||||||
|
const remoteHostsById = new Map((await readRemoteHosts(getDataDir())).map((host) => [host.id, host]));
|
||||||
for (const muxSession of muxSessions) {
|
for (const muxSession of muxSessions) {
|
||||||
if (!this.sessions.has(muxSession.sessionId)) {
|
if (!this.sessions.has(muxSession.sessionId)) {
|
||||||
// Restore session settings from state.json (single source of truth)
|
// Restore session settings from state.json (single source of truth)
|
||||||
@@ -2943,7 +2947,9 @@ export class WebServer extends EventEmitter {
|
|||||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
||||||
// erasing `remote` from state.json on the next persist. mux-sessions.json
|
// erasing `remote` from state.json on the next persist. mux-sessions.json
|
||||||
// round-trips MuxSession.remote; state.json carries SessionState.remote.
|
// round-trips MuxSession.remote; state.json carries SessionState.remote.
|
||||||
remote: muxSession.remote ?? savedState?.remote,
|
// Host-level fields are refreshed from remote-hosts.json on top, or a
|
||||||
|
// field added to the host config after launch would never arrive.
|
||||||
|
remote: rehydrateRemoteHostFields(muxSession.remote ?? savedState?.remote, remoteHostsById),
|
||||||
// Docker metadata round-trips the same way (mux-sessions.json carries
|
// Docker metadata round-trips the same way (mux-sessions.json carries
|
||||||
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
||||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
defaultRemoteCommandForMode,
|
defaultRemoteCommandForMode,
|
||||||
readRemoteCases,
|
readRemoteCases,
|
||||||
readRemoteHosts,
|
readRemoteHosts,
|
||||||
|
rehydrateRemoteHostFields,
|
||||||
remoteDisplayPath,
|
remoteDisplayPath,
|
||||||
remoteSshTarget,
|
remoteSshTarget,
|
||||||
toSessionRemote,
|
toSessionRemote,
|
||||||
@@ -115,4 +116,53 @@ describe('remote-hosts domain', () => {
|
|||||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false);
|
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rehydrateRemoteHostFields', () => {
|
||||||
|
const persisted = {
|
||||||
|
hostId: 'hufflepuff',
|
||||||
|
label: 'Hufflepuff',
|
||||||
|
host: '192.168.50.137',
|
||||||
|
username: 'j',
|
||||||
|
remotePath: '/home/j/work',
|
||||||
|
};
|
||||||
|
const hosts = (wakeCommand?: string) =>
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'hufflepuff',
|
||||||
|
{
|
||||||
|
id: 'hufflepuff',
|
||||||
|
label: 'Hufflepuff',
|
||||||
|
host: '192.168.50.137',
|
||||||
|
username: 'j',
|
||||||
|
...(wakeCommand ? { wakeCommand } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('adds a wake command that only exists in the host config', () => {
|
||||||
|
// The pre-existing-session case: the field was added to remote-hosts.json after
|
||||||
|
// this session was persisted, so recovery is the only place it can arrive.
|
||||||
|
expect(rehydrateRemoteHostFields(persisted, hosts('/home/joe/bin/whuff'))?.wakeCommand).toBe(
|
||||||
|
'/home/joe/bin/whuff'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the host config as authoritative (removing it turns the feature off)', () => {
|
||||||
|
const remote = { ...persisted, wakeCommand: '/home/joe/bin/whuff' };
|
||||||
|
expect(rehydrateRemoteHostFields(remote, hosts())?.wakeCommand).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the block untouched when the host is gone or the session is local', () => {
|
||||||
|
expect(rehydrateRemoteHostFields(persisted, new Map())).toBe(persisted);
|
||||||
|
expect(rehydrateRemoteHostFields(undefined, hosts('/x'))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the other host-level fields as persisted', () => {
|
||||||
|
// Only wakeCommand is refreshed: silently re-pointing an existing pane's ssh
|
||||||
|
// options would be a behavior change nobody asked for.
|
||||||
|
const remote = { ...persisted, identityFile: '~/.ssh/pinned_key' };
|
||||||
|
const rehydrated = rehydrateRemoteHostFields(remote, hosts('/home/joe/bin/whuff'));
|
||||||
|
expect(rehydrated?.identityFile).toBe('~/.ssh/pinned_key');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user