fix: time out automation path probes (#3797)

This commit is contained in:
Neil
2026-05-30 10:45:11 -07:00
committed by GitHub
parent 9e62c7b658
commit 8fe2abdb24
2 changed files with 86 additions and 3 deletions
+49 -2
View File
@@ -1,13 +1,16 @@
/* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createExternalAutomation,
listExternalAutomationManagers,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
} from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { getActiveMultiplexer } from '../ipc/ssh'
import type { Store } from '../persistence'
import type * as Fs from 'fs'
const execFileMock = vi.hoisted(() =>
vi.fn((...args: unknown[]) => {
@@ -16,20 +19,64 @@ const execFileMock = vi.hoisted(() =>
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
execCallback(null, '', '')
}
return { kill: vi.fn() }
})
)
const existsSyncMock = vi.hoisted(() => vi.fn(() => false))
function resolveExecFileMock(...args: unknown[]) {
const callback = args.at(-1)
if (typeof callback === 'function') {
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
execCallback(null, '', '')
}
return { kill: vi.fn() }
}
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
return {
...actual,
existsSync: existsSyncMock
}
})
vi.mock('../ipc/ssh', () => ({
getActiveMultiplexer: vi.fn()
}))
beforeEach(() => {
execFileMock.mockClear()
execFileMock.mockReset()
execFileMock.mockImplementation(resolveExecFileMock)
existsSyncMock.mockReturnValue(false)
vi.mocked(getActiveMultiplexer).mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
describe('listExternalAutomationManagers', () => {
it('settles when a local command lookup hangs', async () => {
vi.useFakeTimers()
execFileMock.mockImplementation(() => ({ kill: vi.fn() }))
const promise = listExternalAutomationManagers({
getSshTargets: () => []
} as unknown as Store)
let settled = false
void promise.finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(5_000)
await Promise.resolve()
expect(settled).toBe(true)
await expect(promise).resolves.toEqual([])
})
})
describe('mapHermesJobs', () => {
it('normalizes Hermes cron jobs into external automation rows', () => {
const jobs = mapHermesJobs('hermes:local', [
+37 -1
View File
@@ -31,6 +31,7 @@ const HERMES_CRON_DIR = join(HERMES_HOME, 'cron')
const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json')
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -39,13 +40,48 @@ function isRecord(value: unknown): value is Record<string, unknown> {
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
await execFileAsync(finder, [command], { encoding: 'utf-8' })
await runLocalCommandLookup(finder, [command])
return true
} catch {
return false
}
}
function runLocalCommandLookup(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
reject(error)
return
}
resolve()
}
// Why: these probes run while loading Automations; a wedged PATH shim must
// not keep the list IPC pending forever.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`))
}, LOCAL_COMMAND_LOOKUP_TIMEOUT_MS)
try {
child = execFile(command, args, { encoding: 'utf-8' }, (error) => {
finish(error ?? null)
})
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
})
}
async function readLocalHermesJobs(): Promise<unknown[]> {
if (!existsSync(HERMES_JOBS_FILE)) {
return []