From e78d12f875b488cb204daecd942ec6ac7596b47a Mon Sep 17 00:00:00 2001 From: WuFenG Date: Wed, 12 Aug 2026 13:39:01 +0800 Subject: [PATCH] feat(sleep-guard): keep the host device awake while IDBots is working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents OS sleep from interrupting running harness work. The main process engages Electron's powerSaveBlocker (prevent-app-suspension) only while at least one work source is active — cowork sessions, running scheduled tasks, nightly dreams — and releases it as soon as everything is idle. - src/main/sleepGuard.ts: pure DI policy + stateful guard (unit-testable) - main.ts wiring: singleton, 20s self-healing refresh, instant triggers on session start/stop and runner complete/error, IPC powerGuard:status and powerGuard:changed broadcast, dispose on cleanup - preload + electron.d.ts: powerGuard API for the harness frontend - Sidebar SleepGuardBadge: shown while engaged, hidden when idle - tests/sleepGuard.test.mjs: 8/8 pass (fake blocker, pure DI) - scripts/sleep-guard-real-host-check.cjs: real-Electron closed loop, 7/7 pass incl. macOS pmset assertion PreventUserIdleSystemSleep (both directions) Also removes a stale unused eslint-disable directive in MetaBotEditTabs.tsx that broke npm run lint on the main baseline (lint-blocker fix). --- scripts/sleep-guard-real-host-check.cjs | 85 ++++++++++ src/main/libs/scheduler.ts | 9 ++ src/main/main.ts | 95 ++++++++++- src/main/preload.ts | 11 ++ src/main/sleepGuard.ts | 152 ++++++++++++++++++ src/renderer/components/Sidebar.tsx | 2 + src/renderer/components/SleepGuardBadge.tsx | 44 +++++ .../components/metabots/MetaBotEditTabs.tsx | 1 - src/renderer/types/electron.d.ts | 4 + tests/sleepGuard.test.mjs | 138 ++++++++++++++++ 10 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 scripts/sleep-guard-real-host-check.cjs create mode 100644 src/main/sleepGuard.ts create mode 100644 src/renderer/components/SleepGuardBadge.tsx create mode 100644 tests/sleepGuard.test.mjs diff --git a/scripts/sleep-guard-real-host-check.cjs b/scripts/sleep-guard-real-host-check.cjs new file mode 100644 index 00000000..e537428e --- /dev/null +++ b/scripts/sleep-guard-real-host-check.cjs @@ -0,0 +1,85 @@ +/** + * Real-host closed-loop check for the Sleep Guard. + * + * Boots the real Electron runtime and drives the COMPILED `sleepGuard` module + * with the REAL `powerSaveBlocker`, asserting engagement in both directions + * (idle -> work -> idle -> work). On macOS it additionally verifies the OS + * power assertion (`PreventUserIdleSystemSleep`) appears while engaged. + * + * Run (from repo root, after `npm run compile:electron`): + * node_modules/.bin/electron scripts/sleep-guard-real-host-check.cjs + * + * Exit code 0 = all checks passed, 1 = any check failed. + */ +const { app, powerSaveBlocker } = require('electron'); +const { execFileSync } = require('child_process'); + +let evaluateSleepGuardWork; +let SleepGuard; +try { + ({ evaluateSleepGuardWork, SleepGuard } = require('../dist-electron/main/sleepGuard.js')); +} catch { + ({ evaluateSleepGuardWork, SleepGuard } = require('../dist-electron/sleepGuard.js')); +} + +const idle = { coworkSessionIds: [], scheduledTaskIds: [], dreamingMetabotIds: [] }; + +function osAssertionActive() { + if (process.platform !== 'darwin') return null; // not applicable + try { + const out = execFileSync('pmset', ['-g', 'assertions'], { encoding: 'utf8' }); + return /PreventUserIdleSystemSleep/.test(out); + } catch { + return null; // pmset unavailable + } +} + +app.whenReady().then(() => { + const results = []; + const check = (name, ok, detail) => { + results.push(ok); + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}: ${detail}`); + }; + + const guard = new SleepGuard({ powerSaveBlocker }); + + // 1. Idle -> guard must NOT engage. + let state = guard.apply(evaluateSleepGuardWork(idle)); + check('idle keeps blocker released', state.active === false && state.engaged === false, JSON.stringify(state)); + + // 2. Work -> guard engages, real powerSaveBlocker isStarted is true. + state = guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['real-host-check-1'] })); + check('work engages blocker', state.active === true && state.engaged === true, JSON.stringify(state)); + + // 3. OS-level assertion appears while engaged (macOS only). + const osActive = osAssertionActive(); + if (osActive === null) { + check('OS assertion (n/a platform)', true, 'not darwin / pmset unavailable'); + } else { + check('OS PreventUserIdleSystemSleep active', osActive, `pmset assertion ${osActive ? 'present' : 'MISSING'}`); + } + + // 4. Repeated apply while working stays engaged (idempotent). + state = guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['real-host-check-1'], scheduledTaskIds: ['t1'], dreamingMetabotIds: [1] })); + check('multi-source apply stays engaged', state.engaged === true && state.sources.includes('dream'), JSON.stringify(state)); + + // 5. Idle again -> blocker released in the real runtime. + state = guard.apply(evaluateSleepGuardWork(idle)); + check('idle releases blocker', state.engaged === false, JSON.stringify(state)); + + // 6. Re-engage (second direction) -> blocker starts again. + state = guard.apply(evaluateSleepGuardWork({ ...idle, scheduledTaskIds: ['t2'] })); + check('re-engage after release', state.engaged === true && state.sources.includes('scheduledTask'), JSON.stringify(state)); + + guard.dispose(); + const afterDispose = guard.isEngaged(); + check('dispose releases blocker', afterDispose === false, `engaged=${afterDispose}`); + + const passed = results.filter(Boolean).length; + const ok = results.every(Boolean); + console.log(`\nRESULT: ${ok ? 'PASS' : 'FAIL'} (${passed}/${results.length})`); + app.exit(ok ? 0 : 1); +}).catch((error) => { + console.error('Real-host check crashed:', error); + app.exit(1); +}); diff --git a/src/main/libs/scheduler.ts b/src/main/libs/scheduler.ts index a0744235..4a147557 100644 --- a/src/main/libs/scheduler.ts +++ b/src/main/libs/scheduler.ts @@ -73,6 +73,15 @@ export class Scheduler { console.log('[Scheduler] Stopped'); } + /** + * Ids of scheduled tasks currently executing (abort controllers live for the + * whole run). Used by the sleep guard to keep the device awake while a + * scheduled task is running. + */ + getActiveTaskIds(): string[] { + return Array.from(this.activeTasks.keys()); + } + reschedule(): void { if (!this.running) return; if (this.timer) { diff --git a/src/main/main.ts b/src/main/main.ts index 9ed9df25..a262d1c4 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, session, nativeTheme, dialog, shell, nativeImage, systemPreferences, Menu } from 'electron'; +import { app, BrowserWindow, ipcMain, session, nativeTheme, dialog, shell, nativeImage, systemPreferences, Menu, powerSaveBlocker } from 'electron'; import type { FileFilter, MessageBoxOptions, OpenDialogOptions, Session, WebContents } from 'electron'; import path from 'path'; import fs from 'fs'; @@ -117,6 +117,7 @@ import { sendEncryptedSimplemsg } from './services/encryptedSimplemsg'; import { performChatCompletionForOrchestrator } from './services/cognitiveChatCompletion'; import { normalizeMetabotLlmId } from './services/llmFallback'; import { startDreamService, stopDreamService, getDreamService } from './services/dreamService'; +import { SleepGuard, evaluateSleepGuardWork, type SleepGuardWorkInput, type SleepGuardState } from './sleepGuard'; import { DreamStore } from './dreamStore'; import { runOrchestratorSkillTurn, runSkillTurnInExistingSession } from './services/orchestratorCoworkBridge'; import { ensureCoworkA2ASession } from './services/coworkEnsureA2ASession'; @@ -2414,6 +2415,8 @@ let coworkStoreHeavyMaintenanceFinished = false; let mcpStore: McpStore | null = null; let coworkRunner: CoworkRunner | null = null; let coworkTurnSubmissionController: CoworkTurnSubmissionController | null = null; +let sleepGuard: SleepGuard | null = null; +let sleepGuardRefreshTimer: ReturnType | null = null; let skillManager: SkillManager | null = null; let metaAppManager: MetaAppManager | null = null; let botBrowserMetaAppCacheService: BotBrowserMetaAppCacheService | null = null; @@ -4341,6 +4344,9 @@ const getCoworkRunner = () => { }); coworkRunner.on('complete', (sessionId: string, claudeSessionId: string | null) => { + // A session finished: recompute the sleep guard so the blocker is + // released as soon as no other work source is active. + recomputeSleepGuard(); if (!shouldForwardCoworkStreamEvent(getCoworkStore(), sessionId)) { return; } @@ -4353,6 +4359,7 @@ const getCoworkRunner = () => { }); coworkRunner.on('error', (sessionId: string, error: string) => { + recomputeSleepGuard(); if (!shouldForwardCoworkStreamEvent(getCoworkStore(), sessionId)) { return; } @@ -5310,8 +5317,77 @@ const getScheduler = () => { return scheduler; }; +// --- Sleep Guard: keep the host device awake while IDBots is working --------- +// Engages Electron's `powerSaveBlocker` only while at least one work source +// (active cowork session / running scheduled task / nightly dream) is active, +// and releases it as soon as everything is idle. Pure policy lives in +// src/main/sleepGuard.ts; this block wires it to the live work sources. +const getSleepGuard = (): SleepGuard => { + if (!sleepGuard) { + sleepGuard = new SleepGuard({ + powerSaveBlocker, + onChanged: (state) => broadcastSleepGuardStatus(state), + }); + broadcastSleepGuardStatus(sleepGuard.getState()); + } + return sleepGuard; +}; + +const collectSleepGuardWork = (): SleepGuardWorkInput => { + let coworkSessionIds: string[] = []; + try { + coworkSessionIds = getCoworkRunner().getActiveSessionIds(); + } catch (error) { + console.warn('[SleepGuard] collect cowork sessions failed:', error); + } + let scheduledTaskIds: string[] = []; + try { + scheduledTaskIds = getScheduler().getActiveTaskIds(); + } catch (error) { + console.warn('[SleepGuard] collect scheduled tasks failed:', error); + } + let dreamingMetabotIds: number[] = []; + try { + dreamingMetabotIds = getDreamService()?.getDreamingBotIds() ?? []; + } catch (error) { + console.warn('[SleepGuard] collect dreaming bots failed:', error); + } + return { coworkSessionIds, scheduledTaskIds, dreamingMetabotIds }; +}; + +const recomputeSleepGuard = (): void => { + try { + getSleepGuard().apply(evaluateSleepGuardWork(collectSleepGuardWork())); + } catch (error) { + console.warn('[SleepGuard] recompute failed:', error); + } +}; + +const broadcastSleepGuardStatus = (state: SleepGuardState): void => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('powerGuard:changed', state); + } +}; + +const startSleepGuardRefresh = (): void => { + if (sleepGuardRefreshTimer) return; + // Periodic safety refresh: self-heals even if an event-driven update was + // missed (e.g. a session ended without its completion event firing). + sleepGuardRefreshTimer = setInterval(recomputeSleepGuard, 20_000); + recomputeSleepGuard(); +}; + +const stopSleepGuardRefresh = (): void => { + if (sleepGuardRefreshTimer) { + clearInterval(sleepGuardRefreshTimer); + sleepGuardRefreshTimer = null; + } + sleepGuard?.dispose(); + sleepGuard = null; +}; + // 获取正确的预加载脚本路径 -const PRELOAD_PATH = app.isPackaged +const PRELOAD_PATH = app.isPackaged ? path.join(__dirname, 'preload.js') : path.join(__dirname, '../dist-electron/preload.js'); @@ -5450,6 +5526,11 @@ if (!gotTheLock) { return getStore().get(key); }); + // Sleep guard status for the harness frontend (badge). + ipcMain.handle('powerGuard:status', () => { + return getSleepGuard().getState(); + }); + ipcMain.handle('store:set', (_event, key, value) => { getStore().set(key, value); }); @@ -6054,6 +6135,9 @@ if (!gotTheLock) { metabotId?: number | null; sessionType?: 'standard' | 'browser'; }) => { + // A session is starting: engage the sleep guard immediately so the device + // does not sleep while the harness is working. + recomputeSleepGuard(); return withSqliteRecovery('cowork:session:start', async () => { try { const coworkStoreInstance = getCoworkStore(); @@ -6209,6 +6293,7 @@ if (!gotTheLock) { try { const runner = getCoworkRunner(); runner.stopSession(sessionId); + recomputeSleepGuard(); return { success: true }; } catch (error) { return { @@ -10375,6 +10460,9 @@ ipcMain.handle('gigSquare:sendOrder', async (_event, params: { // Start the scheduler getScheduler().start(); + + // Start the sleep guard: keep the host device awake while IDBots is working + startSleepGuardRefresh(); }); }; @@ -10382,6 +10470,9 @@ ipcMain.handle('gigSquare:sendOrder', async (_event, params: { let isCleanupInProgress = false; const runAppCleanup = async (): Promise => { + // Release the sleep guard first so the device may sleep normally during + // shutdown even if a later cleanup step stalls. + stopSleepGuardRefresh(); await stopMetaAppServer().catch((error) => { console.error('[metaapps] Failed to stop local server during cleanup:', error); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 28401cee..d6bce021 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -14,6 +14,17 @@ contextBridge.exposeInMainWorld('electron', { set: (key: string, value: any) => ipcRenderer.invoke('store:set', key, value), remove: (key: string) => ipcRenderer.invoke('store:remove', key), }, + powerGuard: { + getStatus: () => ipcRenderer.invoke('powerGuard:status'), + onChanged: (callback: (state: { active: boolean; sources: string[]; engaged: boolean }) => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + state: { active: boolean; sources: string[]; engaged: boolean }, + ) => callback(state); + ipcRenderer.on('powerGuard:changed', handler); + return () => ipcRenderer.removeListener('powerGuard:changed', handler); + }, + }, skills: { list: () => ipcRenderer.invoke('skills:list'), setEnabled: (options: { id: string; enabled: boolean }) => ipcRenderer.invoke('skills:setEnabled', options), diff --git a/src/main/sleepGuard.ts b/src/main/sleepGuard.ts new file mode 100644 index 00000000..962189db --- /dev/null +++ b/src/main/sleepGuard.ts @@ -0,0 +1,152 @@ +/** + * Sleep Guard — keep the host device awake while IDBots is actively working. + * + * IDBots is a desktop platform that keeps working even when the user walks away: + * bot/cowork sessions stream, scheduled tasks fire, nightly dreams consolidate, + * and group-chat tasks orchestrate. If the OS lets the machine sleep during any + * of that work, the work stalls and deliveries are delayed. + * + * This module is a pure, dependency-injected wrapper around Electron's + * `powerSaveBlocker`. It contains no direct `electron` import so it can be + * unit-tested with a fake blocker. The main process wires the real + * `powerSaveBlocker` plus the live "work" sources. + * + * The guard is engaged only while at least one work source is active, and is + * released as soon as all sources are idle — the OS sleep policy is untouched + * outside of actual work. + */ + +export type SleepGuardSource = 'cowork' | 'scheduledTask' | 'dream'; + +export interface SleepGuardWorkInput { + /** Ids of actively-running cowork sessions (covers interactive sessions, + * scheduled-task sessions, group-chat task sessions, A2A chats and + * service-order executions). */ + coworkSessionIds: readonly string[]; + /** Ids of scheduled tasks currently executing (before/around their session). */ + scheduledTaskIds: readonly string[]; + /** Metabot ids currently running a nightly dream consolidation. */ + dreamingMetabotIds: readonly number[]; +} + +export interface SleepGuardWorkState { + /** True when at least one work source is active. */ + active: boolean; + /** The sources that are currently active (empty when idle). */ + sources: SleepGuardSource[]; +} + +export interface SleepGuardState { + active: boolean; + sources: SleepGuardSource[]; + /** Whether the OS power-save blocker is currently engaged. */ + engaged: boolean; +} + +/** Minimal surface of Electron's `powerSaveBlocker` used by this module. */ +export interface PowerSaveBlockerLike { + start(type: 'prevent-app-suspension'): number; + stop(id: number): void; + isStarted(id: number): boolean; +} + +/** + * Pure policy: decide whether the sleep guard must be engaged from the set of + * active work sources. Kept side-effect free so it can be unit-tested directly. + */ +export function evaluateSleepGuardWork(input: SleepGuardWorkInput): SleepGuardWorkState { + const sources: SleepGuardSource[] = []; + if (input.coworkSessionIds.length > 0) sources.push('cowork'); + if (input.scheduledTaskIds.length > 0) sources.push('scheduledTask'); + if (input.dreamingMetabotIds.length > 0) sources.push('dream'); + return { active: sources.length > 0, sources }; +} + +export interface SleepGuardOptions { + powerSaveBlocker: PowerSaveBlockerLike; + /** Called whenever the engaged/active state changes. */ + onChanged?: (state: SleepGuardState) => void; +} + +const BLOCKER_TYPE = 'prevent-app-suspension' as const; + +/** + * Stateful guard: applies work state to the power-save blocker idempotently. + * Starting the blocker when it is already started, or stopping it when it is + * already stopped, is a no-op — callers may `apply` freely on any event. + */ +export class SleepGuard { + private readonly powerSaveBlocker: PowerSaveBlockerLike; + private readonly onChanged?: (state: SleepGuardState) => void; + private blockerId: number | null = null; + private state: SleepGuardState = { active: false, sources: [], engaged: false }; + + constructor(options: SleepGuardOptions) { + this.powerSaveBlocker = options.powerSaveBlocker; + this.onChanged = options.onChanged; + } + + apply(work: SleepGuardWorkState): SleepGuardState { + const prev = this.state; + let engaged = this.blockerId !== null; + + if (work.active) { + if (this.blockerId === null) { + try { + this.blockerId = this.powerSaveBlocker.start(BLOCKER_TYPE); + } catch (error) { + // Blocker start is best-effort (e.g. unsupported platform); keep the + // guard state consistent rather than crashing the caller. + console.warn('[SleepGuard] powerSaveBlocker.start failed:', error); + this.blockerId = null; + } + } + engaged = this.blockerId !== null && this.powerSaveBlocker.isStarted(this.blockerId); + } else if (this.blockerId !== null) { + try { + this.powerSaveBlocker.stop(this.blockerId); + } catch (error) { + console.warn('[SleepGuard] powerSaveBlocker.stop failed:', error); + } + this.blockerId = null; + engaged = false; + } + + const next: SleepGuardState = { active: work.active, sources: [...work.sources], engaged }; + const sourcesChanged = + prev.sources.length !== next.sources.length || + prev.sources.some((source, index) => source !== next.sources[index]); + if ( + prev.active !== next.active || + prev.engaged !== next.engaged || + sourcesChanged + ) { + this.state = next; + this.onChanged?.(next); + } else { + this.state = next; + } + return this.getState(); + } + + getState(): SleepGuardState { + return { active: this.state.active, sources: [...this.state.sources], engaged: this.state.engaged }; + } + + isEngaged(): boolean { + return this.state.engaged; + } + + /** Release the blocker and reset state (used on app shutdown). */ + dispose(): void { + if (this.blockerId !== null) { + try { + this.powerSaveBlocker.stop(this.blockerId); + } catch { + // Already released or the runtime is shutting down. + } + this.blockerId = null; + } + this.state = { active: false, sources: [], engaged: false }; + } +} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 97bc1eec..487cce8b 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { MagnifyingGlassIcon, PlusIcon, ClockIcon, CpuChipIcon, ShoppingBagIcon import ComposeIcon from './icons/ComposeIcon'; import SidebarToggleIcon from './icons/SidebarToggleIcon'; import { P2PStatusBadge } from './p2p/P2PStatusBadge'; +import { SleepGuardBadge } from './SleepGuardBadge'; import { getSidebarPrimaryNavModel } from './sidebar/sidebarNavigation.js'; import BotBrowserModeSwitch from '../features/botBrowser/BotBrowserModeSwitch'; import BotBrowserCoworkPanel from '../features/botBrowser/BotBrowserCoworkPanel'; @@ -324,6 +325,7 @@ const Sidebar: React.FC = ({ {i18nService.t('settings')} + diff --git a/src/renderer/components/SleepGuardBadge.tsx b/src/renderer/components/SleepGuardBadge.tsx new file mode 100644 index 00000000..497deb48 --- /dev/null +++ b/src/renderer/components/SleepGuardBadge.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useState } from 'react'; +import Tooltip from './ui/Tooltip'; + +interface SleepGuardState { + active: boolean; + sources: string[]; + engaged: boolean; +} + +const SOURCE_LABELS: Record = { + cowork: '活跃会话', + scheduledTask: '定时任务', + dream: '夜间梦境', +}; + +/** + * Sidebar badge shown while the sleep guard is engaged (IDBots is working and + * the host device is being kept awake). Hidden when idle. + */ +export const SleepGuardBadge: React.FC = () => { + const [state, setState] = useState({ active: false, sources: [], engaged: false }); + + useEffect(() => { + window.electron.powerGuard.getStatus().then((s) => setState(s as SleepGuardState)); + const unsubscribe = window.electron.powerGuard.onChanged((s) => setState(s as SleepGuardState)); + return () => unsubscribe(); + }, []); + + if (!state.active || !state.engaged) { + return null; + } + + const labels = state.sources.map((source) => SOURCE_LABELS[source] ?? source); + const tooltip = `IDBots 正在工作,已阻止设备休眠(${labels.join(' / ')})`; + + return ( + + + + 防休眠中 + + + ); +}; diff --git a/src/renderer/components/metabots/MetaBotEditTabs.tsx b/src/renderer/components/metabots/MetaBotEditTabs.tsx index 606d1149..81f15889 100644 --- a/src/renderer/components/metabots/MetaBotEditTabs.tsx +++ b/src/renderer/components/metabots/MetaBotEditTabs.tsx @@ -216,7 +216,6 @@ const MetaBotEditTabs: React.FC = ({ setNameDuplicate(false); setSelectedAllowChatSkillId(''); // initialValues is re-created by the parent on every list refresh; key off metabotId only. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [metabotId]); const setTabError = (tab: MetaBotEditTabKey, message: string) => { diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index ac4b78ce..3610a237 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -493,6 +493,10 @@ interface IElectronAPI { set: (key: string, value: any) => Promise; remove: (key: string) => Promise; }; + powerGuard: { + getStatus: () => Promise<{ active: boolean; sources: string[]; engaged: boolean }>; + onChanged: (callback: (state: { active: boolean; sources: string[]; engaged: boolean }) => void) => () => void; + }; skills: { list: () => Promise<{ success: boolean; skills?: Skill[]; error?: string }>; setEnabled: (options: { id: string; enabled: boolean }) => Promise<{ success: boolean; skills?: Skill[]; error?: string }>; diff --git a/tests/sleepGuard.test.mjs b/tests/sleepGuard.test.mjs new file mode 100644 index 00000000..b4f6eebe --- /dev/null +++ b/tests/sleepGuard.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +let evaluateSleepGuardWork; +let SleepGuard; +try { + ({ evaluateSleepGuardWork, SleepGuard } = await import('../dist-electron/main/sleepGuard.js')); +} catch { + ({ evaluateSleepGuardWork, SleepGuard } = await import('../dist-electron/sleepGuard.js')); +} + +function createFakeBlocker() { + const started = new Map(); + let nextId = 1; + return { + start(type) { + assert.equal(type, 'prevent-app-suspension', 'must use prevent-app-suspension'); + const id = nextId++; + started.set(id, true); + return id; + }, + stop(id) { + started.set(id, false); + }, + isStarted(id) { + return started.get(id) === true; + }, + startedCount: () => [...started.values()].filter(Boolean).length, + }; +} + +const idle = { coworkSessionIds: [], scheduledTaskIds: [], dreamingMetabotIds: [] }; + +test('evaluateSleepGuardWork: idle input yields inactive with no sources', () => { + const state = evaluateSleepGuardWork(idle); + assert.equal(state.active, false); + assert.deepEqual(state.sources, []); +}); + +test('evaluateSleepGuardWork: each work source is detected', () => { + assert.deepEqual(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] }), { + active: true, + sources: ['cowork'], + }); + assert.deepEqual(evaluateSleepGuardWork({ ...idle, scheduledTaskIds: ['t1'] }), { + active: true, + sources: ['scheduledTask'], + }); + assert.deepEqual(evaluateSleepGuardWork({ ...idle, dreamingMetabotIds: [1] }), { + active: true, + sources: ['dream'], + }); +}); + +test('evaluateSleepGuardWork: multiple active sources are all reported', () => { + const state = evaluateSleepGuardWork({ + coworkSessionIds: ['s1', 's2'], + scheduledTaskIds: ['t1'], + dreamingMetabotIds: [1, 2, 3], + }); + assert.equal(state.active, true); + assert.deepEqual(state.sources, ['cowork', 'scheduledTask', 'dream']); +}); + +test('SleepGuard: engages the blocker when work starts and releases when idle', () => { + const blocker = createFakeBlocker(); + const guard = new SleepGuard({ powerSaveBlocker: blocker }); + + const engaged = guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] })); + assert.equal(engaged.active, true); + assert.equal(engaged.engaged, true); + assert.equal(blocker.startedCount(), 1, 'blocker started exactly once'); + + const released = guard.apply(evaluateSleepGuardWork(idle)); + assert.equal(released.active, false); + assert.equal(released.engaged, false); + assert.equal(blocker.startedCount(), 0, 'blocker released'); +}); + +test('SleepGuard: apply is idempotent in both directions', () => { + const blocker = createFakeBlocker(); + const guard = new SleepGuard({ powerSaveBlocker: blocker }); + + guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] })); + guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1', 's2'] })); + guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] })); + assert.equal(blocker.startedCount(), 1, 'no double-start while already engaged'); + + guard.apply(evaluateSleepGuardWork(idle)); + guard.apply(evaluateSleepGuardWork(idle)); + assert.equal(blocker.startedCount(), 0, 'no double-stop while already released'); +}); + +test('SleepGuard: onChanged fires only when the state actually changes', () => { + const blocker = createFakeBlocker(); + const changes = []; + const guard = new SleepGuard({ + powerSaveBlocker: blocker, + onChanged: (state) => changes.push(state), + }); + + guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] })); + guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] })); + guard.apply(evaluateSleepGuardWork(idle)); + + assert.equal(changes.length, 2, 'fired on engage and release only'); + assert.equal(changes[0].engaged, true); + assert.equal(changes[1].engaged, false); +}); + +test('SleepGuard: dispose releases the blocker and resets state', () => { + const blocker = createFakeBlocker(); + const guard = new SleepGuard({ powerSaveBlocker: blocker }); + + guard.apply(evaluateSleepGuardWork({ ...idle, dreamingMetabotIds: [7] })); + assert.equal(guard.isEngaged(), true); + + guard.dispose(); + assert.equal(guard.isEngaged(), false); + assert.equal(blocker.startedCount(), 0); + assert.deepEqual(guard.getState(), { active: false, sources: [], engaged: false }); +}); + +test('SleepGuard: blocker start failure degrades gracefully', () => { + const failingBlocker = { + start() { + throw new Error('unsupported platform'); + }, + stop() {}, + isStarted() { + return false; + }, + }; + const guard = new SleepGuard({ powerSaveBlocker: failingBlocker }); + const state = guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['s1'] })); + assert.equal(state.active, true, 'work state stays truthful'); + assert.equal(state.engaged, false, 'blocker engagement reports failure honestly'); +});