mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 01:39:41 +02:00
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>
117 lines
5.1 KiB
TypeScript
117 lines
5.1 KiB
TypeScript
/**
|
|
* @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');
|
|
});
|
|
});
|