feat(power): keep the machine awake while Codeman runs, lid close included

A laptop that suspends freezes every agent session until it wakes. The new
opt-in setting (App Settings > System > Power, off by default, "Only on AC
power" on by default) makes the server hold an OS sleep lock for exactly as
long as it runs:

- Linux: a logind block lock on handle-lid-switch:sleep:idle through
  systemd-inhibit. The lid needs the low-level handle-lid-switch lock
  (LidSwitchIgnoreInhibited=yes ignores a plain sleep lock), and polkit grants
  it only while someone is logged in to the desktop, so a refusal is reported
  as "denied" and retried every minute.
- macOS: caffeinate -i -s -w <pid>. Lid close needs pmset disablesleep, which
  an optional root helper (scripts/keep-awake-macos.sh, a LaunchDaemon run
  from a root-owned copy) applies while the server keeps a fresh request
  file, undoing only a disablesleep it set itself.

The lock dies with the server: systemd-inhibit runs cat on a stdin pipe, so
any exit (SIGKILL included, and under the unit's KillMode=process) releases
it; caffeinate exits with the pid it watches.

The installer asks on laptops (default no, never under --yes), and
`install.sh keep-awake` turns it on for an existing install. Status is at
GET /api/system/keep-awake; a non-admin's value is dropped in multi-user mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 07:21:57 +02:00
parent 3d59e59e37
commit 233f85b170
19 changed files with 1960 additions and 31 deletions
+207
View File
@@ -0,0 +1,207 @@
/**
* @fileoverview install.sh's keep-awake follow-up, driven in a real bash where it can be
* (laptop detection over a fake sysfs, the settings.json write, the --yes default) and
* pinned statically where it cannot (the macOS root helper needs sudo and launchd).
*
* The rules: never turned on by --yes or a headless run; the setting is written before
* the service starts; the root helper runs from a ROOT-OWNED copy, never from the
* user-writable install dir; uninstall undoes only what the helper set.
*
* Port: none.
*/
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'install-keep-awake-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** Source install.sh (library mode) in a real bash with HOME in the temp dir, then run `body`. */
function drive(body: string, env: Record<string, string> = {}) {
const script = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
${body}
`;
const result = spawnSync('bash', ['-c', script, 'bash', INSTALL_SH], {
encoding: 'utf-8',
timeout: 30_000,
input: '',
env: { ...process.env, HOME: join(dir, 'home'), ...env },
});
return result;
}
function fakeSupply(name: string, files: Record<string, string>) {
const d = join(dir, 'ps', name);
mkdirSync(d, { recursive: true });
for (const [k, v] of Object.entries(files)) writeFileSync(join(d, k), `${v}\n`);
}
const laptopCheck = `
POWER_SUPPLY_ROOT="${'$'}FAKE_PS"; LID_BUTTON_ROOT="${'$'}FAKE_LID"
if is_laptop linux; then echo laptop; else echo desktop; fi
`;
describe('is_laptop (Linux)', () => {
const run = () => drive(laptopCheck, { FAKE_PS: join(dir, 'ps'), FAKE_LID: join(dir, 'lid') }).stdout.trim();
it('a machine with no battery and no lid is not a laptop', () => {
mkdirSync(join(dir, 'ps'), { recursive: true });
fakeSupply('AC', { type: 'Mains', online: '1' });
expect(run()).toBe('desktop');
});
it("a wireless mouse's battery does not make a desktop a laptop", () => {
fakeSupply('hidpp_battery_0', { type: 'Battery', scope: 'Device', status: 'Discharging' });
expect(run()).toBe('desktop');
});
it('a system battery or a lid does', () => {
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
expect(run()).toBe('laptop');
rmSync(join(dir, 'ps'), { recursive: true });
mkdirSync(join(dir, 'lid', 'LID0'), { recursive: true });
expect(run()).toBe('laptop');
});
it('copes with a missing power_supply tree', () => {
expect(run()).toBe('desktop');
});
});
describe('write_keep_awake_setting', () => {
const settings = () => join(dir, 'home', '.codeman', 'settings.json');
it('creates settings.json with the setting on and AC-only on', () => {
const r = drive('write_keep_awake_setting');
expect(r.status, r.stderr).toBe(0);
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({ keepAwakeEnabled: true, keepAwakeAcOnly: true });
});
it('keeps every other key, and an explicit AC-only choice', () => {
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
writeFileSync(settings(), JSON.stringify({ theme: 'dark', keepAwakeAcOnly: false }, null, 2));
expect(drive('write_keep_awake_setting').status).toBe(0);
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({
theme: 'dark',
keepAwakeAcOnly: false,
keepAwakeEnabled: true,
});
});
it('leaves a settings file that does not parse untouched', () => {
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
writeFileSync(settings(), '{ not json');
const r = drive('write_keep_awake_setting || echo refused');
expect(r.stdout).toContain('refused');
expect(readFileSync(settings(), 'utf-8')).toBe('{ not json');
});
it('is what keep_awake_enabled_now reads back', () => {
const r = drive(
'keep_awake_enabled_now && echo before; write_keep_awake_setting >/dev/null 2>&1; keep_awake_enabled_now && echo after'
);
expect(r.stdout.trim()).toBe('after');
});
});
describe('choose_keep_awake', () => {
it('--yes on a laptop never turns it on', () => {
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
const r = drive(
`POWER_SUPPLY_ROOT="$FAKE_PS"; ASSUME_YES=1
choose_keep_awake linux
echo "keep=[$KEEP_AWAKE] helper=[$KEEP_AWAKE_LID_HELPER]"`,
{ FAKE_PS: join(dir, 'ps') }
);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('keep=[] helper=[]');
});
it('asks nothing on a desktop', () => {
const r = drive(
`POWER_SUPPLY_ROOT="$FAKE_PS"; LID_BUTTON_ROOT="$FAKE_PS"
choose_keep_awake linux 2>&1
echo "keep=[$KEEP_AWAKE]"`,
{ FAKE_PS: join(dir, 'nothing') }
);
expect(r.stdout.trim()).toBe('keep=[]');
});
it('only notes an existing setting, without re-asking', () => {
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
writeFileSync(join(dir, 'home', '.codeman', 'settings.json'), JSON.stringify({ keepAwakeEnabled: true }, null, 2));
const r = drive(`choose_keep_awake linux force 2>&1; echo "keep=[$KEEP_AWAKE]"`);
expect(r.stdout).toContain('already on');
expect(r.stdout).toContain('keep=[]');
});
it('defaults to no in the prompt itself', () => {
const fn = SOURCE.slice(SOURCE.indexOf('choose_keep_awake() {'), SOURCE.indexOf('write_keep_awake_setting() {'));
expect(fn).toMatch(/prompt_yes_no "Keep this machine awake[^"]*" "n"/);
// The sudo-needing helper is offered only to a person at a terminal.
expect(fn).toMatch(/"\$NONINTERACTIVE" != "1" && "\$ASSUME_YES" != "1" \]\] && has_tty/);
});
});
describe('install.sh keep-awake wiring', () => {
const fn = (name: string, next: string) => SOURCE.slice(SOURCE.indexOf(`${name}() {`), SOURCE.indexOf(next));
it('asks after question 3 and applies before the service starts', () => {
const main = fn('main', '\npreflight_detect() {');
const ask = main.indexOf('choose_keep_awake "$os"');
const apply = main.indexOf('apply_keep_awake');
expect(ask).toBeGreaterThan(main.indexOf('choose_launch_mode "$os"'));
expect(ask).toBeLessThan(main.indexOf('install_or_update_repo'));
expect(apply).toBeGreaterThan(main.indexOf('run_step "Building Codeman"'));
expect(apply).toBeLessThan(main.indexOf('setup_launchd_service'));
expect(apply).toBeLessThan(main.indexOf('setup_systemd_service'));
});
it('runs the root helper from a root-owned copy, never from the install dir', () => {
const body = fn('install_keep_awake_lid_helper', '\nremove_keep_awake_lid_helper() {');
expect(body).toContain('install -m 755 -o root -g wheel "$src" "$script"');
expect(body).toContain('local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"');
const programArgs = body.slice(body.indexOf('<key>ProgramArguments</key>'), body.indexOf('</array>'));
expect(programArgs).toContain('$(xml_escape "$script")');
expect(programArgs).not.toContain('INSTALL_DIR');
// The request path must match what the server writes (dataPath('keep-awake-lid.pid')).
expect(body).toContain('local request="$HOME/.codeman/keep-awake-lid.pid"');
});
it('uninstall removes the helper and undoes only a disablesleep it set', () => {
expect(fn('uninstall', '\nusage() {')).toContain('remove_keep_awake_lid_helper');
const remove = fn('remove_keep_awake_lid_helper', '\napply_keep_awake() {');
const owned = remove.indexOf('keep-awake.owned');
expect(owned).toBeGreaterThan(-1);
expect(remove.indexOf('pmset -a disablesleep 0')).toBeGreaterThan(owned);
});
it('dispatches the keep-awake subcommand and documents it', () => {
expect(SOURCE).toMatch(/update\|uninstall\|tailscale\|name\|status\|cloudflared\|keep-awake\)/);
expect(SOURCE).toMatch(/\n {4}keep-awake\) {2}keep_awake_subcommand ;;/);
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
expect(header).toContain('install.sh keep-awake');
expect(fn('usage', '\nparse_flags() {')).toContain('keep-awake');
});
it('update and keep-awake share one restart path', () => {
expect(fn('update', '\nuninstall() {')).toContain('restart_running_service');
expect(fn('keep_awake_subcommand', '\nverify_systemd_active() {')).toContain('restart_running_service');
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview Runs the real macOS lid helper (scripts/keep-awake-macos.sh) in bash
* against a stub `pmset` that records what it was asked to do. The helper runs as root
* from a LaunchDaemon, so the cases that matter are the ones where it must NOT act: a
* stale, dead, foreign or symlinked request, and an administrator's own
* `disablesleep 1`, which it must never switch off.
*
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
* here, so this passes on Linux and macOS alike. Port: none.
*/
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const HELPER = fileURLToPath(new URL('../scripts/keep-awake-macos.sh', import.meta.url));
let dir: string;
let stateDir: string;
let request: string;
/** Stub pmset: `-g` prints the stored SleepDisabled, `-a disablesleep N` stores N and logs it. */
function writeStubPmset(initial: '0' | '1' | null) {
const value = join(dir, 'sleepdisabled');
if (initial !== null) writeFileSync(value, initial);
const stub = join(dir, 'pmset');
writeFileSync(
stub,
`#!/bin/bash
if [ "$1" = "-g" ]; then
echo "System-wide power settings:"
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
echo "Currently in use:"
echo " sleep 1"
exit 0
fi
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
printf '%s' "$3" > "${value}"
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
exit 0
fi
exit 1
`
);
chmodSync(stub, 0o755);
return stub;
}
function run() {
const result = spawnSync('bash', [HELPER, request], {
encoding: 'utf-8',
env: {
...process.env,
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
},
});
expect(result.status, result.stderr).toBe(0);
}
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
const requestFromLiveServer = () => writeFileSync(request, `${process.pid}\n`);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'keep-awake-helper-'));
stateDir = join(dir, 'state');
request = join(dir, 'keep-awake-lid.pid');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('keep-awake-macos.sh', () => {
it('does nothing without a request', () => {
writeStubPmset('0');
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
});
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
writeStubPmset('0');
requestFromLiveServer();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
run(); // steady state: no repeated pmset call
expect(calls()).toBe('disablesleep 1\n');
rmSync(request);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
});
it("never switches off an administrator's own disablesleep", () => {
writeStubPmset('1');
requestFromLiveServer();
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
rmSync(request);
run();
expect(sleepDisabled()).toBe('1');
expect(calls()).toBe('');
});
it('treats an absent SleepDisabled line as 0', () => {
writeStubPmset(null);
requestFromLiveServer();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
});
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
writeStubPmset('0');
requestFromLiveServer();
run();
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
run();
expect(sleepDisabled()).toBe('1');
});
it('ignores a stale request (a crashed or hung server) and releases', () => {
writeStubPmset('0');
requestFromLiveServer();
run();
expect(sleepDisabled()).toBe('1');
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('ignores a request whose pid is not running', () => {
writeStubPmset('0');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(calls()).toBe('');
});
it('ignores garbage and refuses a symlinked request', () => {
writeStubPmset('0');
writeFileSync(request, 'not-a-pid\n');
run();
expect(calls()).toBe('');
rmSync(request);
const real = join(dir, 'elsewhere.pid');
writeFileSync(real, `${process.pid}\n`);
symlinkSync(real, request);
run();
expect(calls()).toBe('');
});
});
+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview KeepAwakeManager (src/keep-awake-manager.ts) driven through fake deps: no
* real lock is ever taken. Pins the lifecycle: a lock is confirmed before it reads as
* active, a polkit refusal is a retried state, AC-only follows the power source, a
* release closes the inhibitor's stdin (which is what drops the lock), and the macOS lid
* request file follows the lock only when the root helper is installed.
*
* Port: none.
*/
import { EventEmitter } from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { KeepAwakeManager, type KeepAwakeDeps } from '../src/keep-awake-manager.js';
import {
LINUX_HELD_MARKER,
POWER_POLL_MS,
RETRY_MS,
linuxInhibitArgs,
macCaffeinateArgs,
type PowerSupplyInfo,
} from '../src/keep-awake.js';
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
/** Like the real `cat`: closing stdin ends the process (asynchronously). */
stdin = Object.assign(new EventEmitter(), {
end: vi.fn(() => {
queueMicrotask(() => {
if (this.exitCode !== null || this.signalCode !== null) return;
this.exitCode = 0;
this.emit('exit', 0, null);
});
}),
});
exitCode: number | null = null;
signalCode: string | null = null;
kill = vi.fn((signal?: string) => {
this.signalCode = signal ?? 'SIGTERM';
this.emit('exit', null, this.signalCode);
return true;
});
/** The process announces the lock (systemd-inhibit only execs its child once held). */
hold() {
this.stdout.emit('data', Buffer.from(`${LINUX_HELD_MARKER}\n`));
}
fail(stderr: string, code = 1) {
this.stderr.emit('data', Buffer.from(stderr));
this.exitCode = code;
this.emit('exit', code, null);
}
}
const AC: PowerSupplyInfo[] = [
{ type: 'Mains', online: true },
{ type: 'Battery', status: 'Charging' },
];
const BATTERY: PowerSupplyInfo[] = [
{ type: 'Mains', online: false },
{ type: 'Battery', status: 'Discharging' },
];
/** Let queued reconciles (a promise chain) run to completion. */
const settle = async () => {
for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r));
};
function makeDeps(overrides: Partial<KeepAwakeDeps> = {}) {
const children: FakeChild[] = [];
let supplies = AC;
let batt = "Now drawing from 'AC Power'";
const deps: KeepAwakeDeps = {
platform: 'linux',
serverPid: 4242,
spawn: vi.fn(() => {
const c = new FakeChild();
children.push(c);
return c as unknown as ChildProcess;
}),
readPowerSupplies: vi.fn(async () => supplies),
readMacBatt: vi.fn(async () => batt),
lidHelperInstalled: vi.fn(async () => true),
writeLidRequest: vi.fn(async () => {}),
removeLidRequest: vi.fn(async () => {}),
...overrides,
};
return {
deps,
children,
setSupplies: (s: PowerSupplyInfo[]) => (supplies = s),
setBatt: (s: string) => (batt = s),
};
}
describe('KeepAwakeManager on Linux', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
});
afterEach(() => {
vi.useRealTimers();
});
it('takes the logind lock and reads active only once the lock is confirmed', async () => {
const { deps, children } = makeDeps();
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
expect(deps.spawn).toHaveBeenCalledWith('systemd-inhibit', linuxInhibitArgs());
expect(m.getStatus().state).toBe('starting');
children[0].hold();
expect(m.getStatus()).toMatchObject({ state: 'active', onAc: true, platform: 'linux', lidHelper: null });
await m.stop();
});
it('reports a polkit refusal as denied and retries it', async () => {
const { deps, children } = makeDeps();
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
children[0].fail('Failed to inhibit: Access denied\n');
expect(m.getStatus().state).toBe('denied');
expect(deps.spawn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(RETRY_MS);
await settle();
expect(deps.spawn).toHaveBeenCalledTimes(2);
children[1].hold();
expect(m.getStatus().state).toBe('active');
await m.stop();
});
it('turning it off closes the inhibitor stdin, which drops the lock, and does not retry', async () => {
const { deps, children } = makeDeps();
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: false });
children[0].hold();
await m.apply({ enabled: false, acOnly: false });
expect(children[0].stdin.end).toHaveBeenCalled();
expect(m.getStatus().state).toBe('off');
// The released child exiting is expected, not a failure to retry.
await settle();
await vi.advanceTimersByTimeAsync(RETRY_MS * 2);
await settle();
expect(deps.spawn).toHaveBeenCalledTimes(1);
expect(m.getStatus().state).toBe('off');
});
it('AC-only: no lock on battery, takes it when plugged in, releases on unplug', async () => {
const { deps, children, setSupplies } = makeDeps();
setSupplies(BATTERY);
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
expect(deps.spawn).not.toHaveBeenCalled();
expect(m.getStatus()).toMatchObject({ state: 'paused-battery', onAc: false });
setSupplies(AC);
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
await settle();
expect(deps.spawn).toHaveBeenCalledTimes(1);
children[0].hold();
expect(m.getStatus().state).toBe('active');
setSupplies(BATTERY);
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
await settle();
expect(children[0].stdin.end).toHaveBeenCalled();
expect(m.getStatus().state).toBe('paused-battery');
await m.stop();
});
it('without AC-only it never reads the power source', async () => {
const { deps, setSupplies } = makeDeps();
setSupplies(BATTERY);
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: false });
expect(deps.readPowerSupplies).not.toHaveBeenCalled();
expect(deps.spawn).toHaveBeenCalledTimes(1);
await m.stop();
});
it('a machine without systemd-inhibit is unavailable, and is not retried', async () => {
const { deps } = makeDeps({
spawn: vi.fn(() => {
throw Object.assign(new Error('spawn systemd-inhibit ENOENT'), { code: 'ENOENT' });
}),
});
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
expect(m.getStatus()).toMatchObject({ state: 'unavailable' });
expect(m.getStatus().detail).toMatch(/systemd-inhibit is not installed/);
await vi.advanceTimersByTimeAsync(RETRY_MS * 3);
await settle();
expect(deps.spawn).toHaveBeenCalledTimes(1);
await m.stop();
});
it('stop() releases the lock and leaves nothing scheduled', async () => {
const { deps, children } = makeDeps();
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
children[0].hold();
await m.stop();
await settle();
expect(children[0].stdin.end).toHaveBeenCalled();
expect(children[0].kill).not.toHaveBeenCalled();
expect(m.getStatus().state).toBe('off');
expect(vi.getTimerCount()).toBe(0);
});
});
describe('KeepAwakeManager on macOS', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
});
afterEach(() => {
vi.useRealTimers();
});
it('runs caffeinate tied to the server pid and keeps the lid request fresh while held', async () => {
const { deps, children } = makeDeps({ platform: 'macos' });
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
expect(deps.spawn).toHaveBeenCalledWith('/usr/bin/caffeinate', macCaffeinateArgs(4242));
children[0].emit('spawn');
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'installed', onAc: true });
expect(deps.writeLidRequest).toHaveBeenCalledWith(4242);
await vi.advanceTimersByTimeAsync(30_000);
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
await m.apply({ enabled: false, acOnly: true });
expect(deps.removeLidRequest).toHaveBeenCalled();
expect(children[0].kill).toHaveBeenCalled();
const writes = (deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBe(writes);
});
it('writes no lid request when the root helper is not installed', async () => {
const { deps, children } = makeDeps({ platform: 'macos', lidHelperInstalled: vi.fn(async () => false) });
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
children[0].emit('spawn');
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'missing' });
expect(deps.writeLidRequest).not.toHaveBeenCalled();
await m.stop();
});
it('on battery with AC-only, drops both caffeinate and the lid request', async () => {
const { deps, children, setBatt } = makeDeps({ platform: 'macos' });
const m = new KeepAwakeManager(deps);
await m.apply({ enabled: true, acOnly: true });
children[0].emit('spawn');
setBatt("Now drawing from 'Battery Power'");
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
await settle();
expect(m.getStatus().state).toBe('paused-battery');
expect(children[0].kill).toHaveBeenCalled();
expect(deps.removeLidRequest).toHaveBeenCalled();
await m.stop();
});
});
describe('the process-wide manager under vitest', () => {
it('is inert: enabling it never spawns a real lock', async () => {
const m = new KeepAwakeManager();
await m.apply({ enabled: true, acOnly: false });
expect(m.getStatus()).toMatchObject({ state: 'unavailable', detail: 'Disabled under the test runner.' });
await m.stop();
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs"
* (src/keep-awake.ts): settings defaults, platform choice, power-source parsing, the
* hold/release rule, the lock argv and the failure classification.
*
* Port: none (pure).
*/
import { describe, expect, it } from 'vitest';
import {
LINUX_HELD_MARKER,
LINUX_INHIBIT_WHAT,
classifyInhibitFailure,
isOnAcPowerLinux,
isOnAcPowerMac,
keepAwakePlatform,
linuxInhibitArgs,
macCaffeinateArgs,
resolveKeepAwakeConfig,
shouldHoldLock,
} from '../src/keep-awake.js';
describe('resolveKeepAwakeConfig', () => {
it('is off unless explicitly enabled, and AC-only unless explicitly not', () => {
expect(resolveKeepAwakeConfig({})).toEqual({ enabled: false, acOnly: true });
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: 'yes' })).toEqual({ enabled: false, acOnly: true });
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true })).toEqual({ enabled: true, acOnly: true });
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true, keepAwakeAcOnly: false })).toEqual({
enabled: true,
acOnly: false,
});
});
});
describe('keepAwakePlatform', () => {
it('maps darwin and linux, and treats WSL as unsupported', () => {
expect(keepAwakePlatform('darwin', '24.0.0')).toBe('macos');
expect(keepAwakePlatform('linux', '6.8.0-124-generic')).toBe('linux');
expect(keepAwakePlatform('linux', '5.15.153.1-microsoft-standard-WSL2')).toBe('unsupported');
expect(keepAwakePlatform('win32', '10.0.22631')).toBe('unsupported');
});
});
describe('isOnAcPowerLinux', () => {
const battery = (status: string) => ({ type: 'Battery', status });
it('a machine with no battery of its own is on external power', () => {
expect(isOnAcPowerLinux([])).toBe(true);
expect(isOnAcPowerLinux([{ type: 'Battery', status: 'Discharging', scope: 'Device' }])).toBe(true);
});
it('an online adapter means AC, all adapters offline means battery', () => {
expect(isOnAcPowerLinux([{ type: 'Mains', online: true }, battery('Charging')])).toBe(true);
expect(isOnAcPowerLinux([{ type: 'USB', online: true }, battery('Unknown')])).toBe(true);
expect(isOnAcPowerLinux([{ type: 'Mains', online: false }, battery('Unknown')])).toBe(false);
});
it('falls back to the battery status when no adapter is listed', () => {
expect(isOnAcPowerLinux([battery('Discharging')])).toBe(false);
expect(isOnAcPowerLinux([battery('Full')])).toBe(true);
expect(isOnAcPowerLinux([battery('Not charging')])).toBe(true);
expect(isOnAcPowerLinux([battery('Unknown')])).toBe(null);
});
});
describe('isOnAcPowerMac', () => {
it('reads the source from `pmset -g batt`', () => {
expect(isOnAcPowerMac("Now drawing from 'AC Power'\n -InternalBattery-0 (id=1)\t100%; charged;")).toBe(true);
expect(isOnAcPowerMac("Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t80%;")).toBe(false);
expect(isOnAcPowerMac("Now drawing from 'UPS Power'")).toBe(false);
expect(isOnAcPowerMac('')).toBe(null);
});
});
describe('shouldHoldLock', () => {
it('holds only when enabled, and on battery only when not AC-only', () => {
expect(shouldHoldLock({ enabled: false, acOnly: false }, true)).toBe(false);
expect(shouldHoldLock({ enabled: true, acOnly: true }, true)).toBe(true);
expect(shouldHoldLock({ enabled: true, acOnly: true }, false)).toBe(false);
expect(shouldHoldLock({ enabled: true, acOnly: false }, false)).toBe(true);
});
it('treats an unknown power source as AC (a desktop without battery reporting)', () => {
expect(shouldHoldLock({ enabled: true, acOnly: true }, null)).toBe(true);
});
});
describe('lock argv', () => {
it('asks logind for the lid lock, not just sleep (LidSwitchIgnoreInhibited=yes ignores sleep)', () => {
const args = linuxInhibitArgs();
expect(LINUX_INHIBIT_WHAT.split(':')).toContain('handle-lid-switch');
expect(args).toContain(`--what=${LINUX_INHIBIT_WHAT}`);
expect(args).toContain('--mode=block');
});
it('ends in a cat on stdin, so the lock dies with the server', () => {
const args = linuxInhibitArgs();
const script = args[args.length - 1];
expect(args.slice(-3, -1)).toEqual(['/bin/sh', '-c']);
expect(script).toBe(`echo ${LINUX_HELD_MARKER}; exec cat`);
expect(script).not.toMatch(/sleep/);
});
it('ties caffeinate to the server pid', () => {
expect(macCaffeinateArgs(4242)).toEqual(['-i', '-s', '-w', '4242']);
});
});
describe('classifyInhibitFailure', () => {
it('reads a polkit refusal as denied', () => {
expect(classifyInhibitFailure('Failed to inhibit: Access denied\n').state).toBe('denied');
expect(classifyInhibitFailure('Interactive authentication required.').state).toBe('denied');
});
it('reads a missing logind as unavailable', () => {
expect(classifyInhibitFailure('Failed to connect to bus: No such file or directory').state).toBe('unavailable');
});
it('keeps the first line of anything else', () => {
const r = classifyInhibitFailure('something odd\nmore');
expect(r).toEqual({ state: 'failed', detail: 'something odd' });
expect(classifyInhibitFailure('').detail).toBe('the inhibitor exited');
});
});
@@ -0,0 +1,132 @@
/**
* @fileoverview Keep-awake wiring in system-routes: PUT /api/settings reconciles the sleep
* lock from the MERGED settings (a partial body never reads as "turn it off"), a
* non-admin's value is dropped in multi-user mode (it is machine state), and
* GET /api/system/keep-awake returns the manager's status in the standard envelope.
*
* Uses app.inject(); the manager is mocked, so no real lock is taken. Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
const { store, keepAwake } = vi.hoisted(() => ({
store: { settings: {} as Record<string, unknown>, written: [] as Record<string, unknown>[] },
keepAwake: {
apply: vi.fn(async () => {}),
getStatus: vi.fn(() => ({
enabled: true,
acOnly: true,
platform: 'linux',
state: 'active',
onAc: true,
lidHelper: null,
detail: null,
})),
stop: vi.fn(async () => {}),
},
}));
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(store.settings)),
writeFile: vi.fn(async (_path: string, data: string) => {
store.written.push(JSON.parse(data));
}),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/keep-awake-manager.js', () => ({ keepAwake }));
describe('keep-awake in system routes', () => {
let harness: RouteTestHarness;
beforeEach(() => {
store.settings = {};
store.written = [];
keepAwake.apply.mockClear();
delete process.env.CODEMAN_MULTIUSER;
});
afterEach(async () => {
delete process.env.CODEMAN_MULTIUSER;
await harness?.app.close();
});
it('a partial PUT keeps the persisted keep-awake on', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
store.settings = { keepAwakeEnabled: true, keepAwakeAcOnly: false };
const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showMonitor: true } });
expect(res.statusCode).toBe(200);
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: false });
});
it('an explicit PUT turns it on and persists it', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(store.written.at(-1)).toMatchObject({ keepAwakeEnabled: true });
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
});
it('rejects a non-boolean value', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: 'yes' },
});
expect(res.statusCode).toBe(400);
expect(keepAwake.apply).not.toHaveBeenCalled();
});
it("drops a non-admin's value in multi-user mode, without failing the rest of the save", async () => {
process.env.CODEMAN_MULTIUSER = '1';
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'bob', role: 'user' },
});
store.settings = { keepAwakeEnabled: false };
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: true, keepAwakeAcOnly: false, showMonitor: true },
});
expect(res.statusCode).toBe(200);
const written = store.written.at(-1)!;
expect(written.keepAwakeEnabled).toBe(false);
expect('keepAwakeAcOnly' in written).toBe(false);
expect(written.showMonitor).toBe(true);
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: false, acOnly: true });
});
it('an admin can change it in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { keepAwakeEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
});
it('GET /api/system/keep-awake returns the status envelope', async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
const res = await harness.app.inject({ method: 'GET', url: '/api/system/keep-awake' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: keepAwake.getStatus() });
});
});