mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Split external automation manager
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
ExternalAutomationManager,
|
||||
ExternalAutomationProvider
|
||||
} from '../../shared/automations-types'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
|
||||
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
|
||||
import { readHermesCronOutputRunsPage } from './hermes-cron-output'
|
||||
import { isExternalAutomationCommandOnPath } from './external-manager-local-command'
|
||||
import { externalAutomationRelayErrorMessage } from './external-manager-relay'
|
||||
|
||||
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
|
||||
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 PROVIDER_LABELS: Record<ExternalAutomationProvider, string> = {
|
||||
hermes: 'Hermes',
|
||||
openclaw: 'OpenClaw'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function readLocalHermesJobs(): Promise<unknown[]> {
|
||||
if (!existsSync(HERMES_JOBS_FILE)) {
|
||||
return []
|
||||
}
|
||||
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(content) as unknown
|
||||
const jobs = Array.isArray(parsed)
|
||||
? parsed
|
||||
: isRecord(parsed) && Array.isArray(parsed.jobs)
|
||||
? parsed.jobs
|
||||
: []
|
||||
return Promise.all(
|
||||
jobs.map(async (job) => {
|
||||
if (!isRecord(job)) {
|
||||
return job
|
||||
}
|
||||
const jobId = typeof job.id === 'string' ? job.id : null
|
||||
if (!jobId) {
|
||||
return job
|
||||
}
|
||||
const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 })
|
||||
return {
|
||||
...job,
|
||||
run_count: runsPage.total,
|
||||
runs: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function readLocalOpenClawJobs(): Promise<unknown[]> {
|
||||
if (!existsSync(OPENCLAW_JOBS_FILE)) {
|
||||
return []
|
||||
}
|
||||
const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(content) as unknown
|
||||
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
|
||||
}
|
||||
|
||||
function mapProviderJobs(
|
||||
provider: ExternalAutomationProvider,
|
||||
managerId: string,
|
||||
jobs: unknown[]
|
||||
): ExternalAutomationManager['jobs'] {
|
||||
return provider === 'hermes' ? mapHermesJobs(managerId, jobs) : mapOpenClawJobs(managerId, jobs)
|
||||
}
|
||||
|
||||
export async function listLocalManager(
|
||||
provider: ExternalAutomationProvider
|
||||
): Promise<ExternalAutomationManager | null> {
|
||||
const [availableResult, jobsResult] = await Promise.allSettled([
|
||||
isExternalAutomationCommandOnPath(provider),
|
||||
provider === 'hermes' ? readLocalHermesJobs() : readLocalOpenClawJobs()
|
||||
])
|
||||
const available = availableResult.status === 'fulfilled' && availableResult.value
|
||||
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
|
||||
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
|
||||
if (!available && jobs.length === 0 && !readError) {
|
||||
return null
|
||||
}
|
||||
const managerId = `${provider}:local`
|
||||
const providerLabel = PROVIDER_LABELS[provider]
|
||||
return {
|
||||
id: managerId,
|
||||
provider,
|
||||
label: `${providerLabel} on this computer`,
|
||||
targetLabel: 'this computer',
|
||||
target: { type: 'local' },
|
||||
status: readError ? 'unavailable' : 'available',
|
||||
error:
|
||||
readError ??
|
||||
(available
|
||||
? null
|
||||
: `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`),
|
||||
canManage: !readError && available,
|
||||
jobs: mapProviderJobs(provider, managerId, jobs)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRemoteManager(
|
||||
target: SshTarget,
|
||||
provider: ExternalAutomationProvider
|
||||
): Promise<ExternalAutomationManager> {
|
||||
const providerLabel = PROVIDER_LABELS[provider]
|
||||
const managerProviderId = `${provider}:ssh:${target.id}`
|
||||
const mux = getActiveMultiplexer(target.id)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
return {
|
||||
id: managerProviderId,
|
||||
provider,
|
||||
label: `${providerLabel} on ${target.label}`,
|
||||
targetLabel: target.label,
|
||||
target: { type: 'ssh', connectionId: target.id },
|
||||
status: 'unavailable',
|
||||
error: 'SSH target is not connected.',
|
||||
canManage: false,
|
||||
jobs: []
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = (await mux.request('externalAutomations.list', { provider })) as {
|
||||
jobs?: unknown[]
|
||||
hermesAvailable?: boolean
|
||||
openclawAvailable?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
const commandAvailable =
|
||||
provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true
|
||||
const readError = result.error ?? null
|
||||
return {
|
||||
id: managerProviderId,
|
||||
provider,
|
||||
label: `${providerLabel} on ${target.label}`,
|
||||
targetLabel: target.label,
|
||||
target: { type: 'ssh', connectionId: target.id },
|
||||
status: readError ? 'unavailable' : 'available',
|
||||
error:
|
||||
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
|
||||
canManage: !readError && commandAvailable,
|
||||
jobs: mapProviderJobs(provider, managerProviderId, result.jobs ?? [])
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: managerProviderId,
|
||||
provider,
|
||||
label: `${providerLabel} on ${target.label}`,
|
||||
targetLabel: target.label,
|
||||
target: { type: 'ssh', connectionId: target.id },
|
||||
status: 'unavailable',
|
||||
error: externalAutomationRelayErrorMessage(error),
|
||||
canManage: false,
|
||||
jobs: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
|
||||
|
||||
export function assertExternalAutomationJobId(jobId: string): void {
|
||||
if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) {
|
||||
throw new Error('Invalid external automation job ID.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000
|
||||
const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000
|
||||
|
||||
function runLocalProviderCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeoutMs: number; timeoutMessage: string }
|
||||
): Promise<void> {
|
||||
return runProcess({ program: command, args, timeoutMs: options.timeoutMs }).then((result) => {
|
||||
if (result.timedOut) {
|
||||
throw new Error(options.timeoutMessage)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || `Command exited with code ${result.code ?? 'unknown'}.`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function isExternalAutomationCommandOnPath(command: string): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
// Why: these probes run while loading Automations; a wedged PATH shim must
|
||||
// not keep the list IPC pending forever.
|
||||
await runLocalProviderCommand(finder, [command], {
|
||||
timeoutMs: LOCAL_COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
timeoutMessage: `Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Why: local automation mutations back UI actions; a wedged CLI must not keep
|
||||
// create/edit/run/delete pending after Node signals a timeout.
|
||||
export function runLocalAutomationCommand(command: string, args: string[]): Promise<void> {
|
||||
return runLocalProviderCommand(command, args, {
|
||||
timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS,
|
||||
timeoutMessage: `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
ExternalAutomationAction,
|
||||
ExternalAutomationActionInput,
|
||||
ExternalAutomationCreateInput,
|
||||
ExternalAutomationProvider,
|
||||
ExternalAutomationUpdateInput
|
||||
} from '../../shared/automations-types'
|
||||
import { assertExternalAutomationJobId } from './external-manager-job-id'
|
||||
import { runLocalAutomationCommand } from './external-manager-local-command'
|
||||
import { requireExternalAutomationMultiplexer } from './external-manager-relay'
|
||||
import { clearHermesCronOutputRunCountCache } from './hermes-cron-output'
|
||||
|
||||
const PROVIDER_ACTION_COMMANDS: Record<
|
||||
ExternalAutomationProvider,
|
||||
Record<ExternalAutomationAction, string>
|
||||
> = {
|
||||
hermes: { pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' },
|
||||
openclaw: { pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' }
|
||||
}
|
||||
|
||||
/** Fails closed like its neighbours: an unlisted or inherited key is not an action. */
|
||||
function providerActionCommand(
|
||||
provider: ExternalAutomationProvider,
|
||||
action: ExternalAutomationAction
|
||||
): string {
|
||||
const commands = Object.hasOwn(PROVIDER_ACTION_COMMANDS, provider)
|
||||
? PROVIDER_ACTION_COMMANDS[provider]
|
||||
: null
|
||||
const command = commands && Object.hasOwn(commands, action) ? commands[action] : null
|
||||
if (typeof command !== 'string') {
|
||||
throw new Error('Unsupported external automation action.')
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): {
|
||||
name: string
|
||||
prompt: string
|
||||
schedule: string
|
||||
workdir: string | null
|
||||
} {
|
||||
if (input.provider !== 'hermes') {
|
||||
throw new Error('Only Hermes cron creation and editing are supported.')
|
||||
}
|
||||
const name = input.name.trim()
|
||||
const prompt = input.prompt.trim()
|
||||
const schedule = input.schedule.trim()
|
||||
const workdir = input.workdir?.trim() || null
|
||||
if (!prompt) {
|
||||
throw new Error('Hermes cron requires a prompt.')
|
||||
}
|
||||
if (!schedule) {
|
||||
throw new Error('Hermes cron requires a schedule.')
|
||||
}
|
||||
return {
|
||||
name: name || prompt.slice(0, 50).trim() || 'Hermes cron',
|
||||
prompt,
|
||||
schedule,
|
||||
workdir
|
||||
}
|
||||
}
|
||||
|
||||
/** Edit args when `jobId` is given, create args otherwise; both are argv, never a shell string. */
|
||||
function hermesCronMutationArgs(
|
||||
jobId: string | null,
|
||||
input: { name: string; prompt: string; schedule: string; workdir: string | null }
|
||||
): string[] {
|
||||
const args = jobId
|
||||
? ['cron', 'edit', jobId, '--schedule', input.schedule, '--prompt', input.prompt]
|
||||
: ['cron', 'create', input.schedule, input.prompt]
|
||||
args.push('--name', input.name)
|
||||
if (!jobId) {
|
||||
args.push('--deliver', 'local')
|
||||
}
|
||||
if (input.workdir) {
|
||||
args.push('--workdir', input.workdir)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
export async function createExternalAutomation(
|
||||
input: ExternalAutomationCreateInput
|
||||
): Promise<void> {
|
||||
const normalized = normalizeHermesCronMutationInput(input)
|
||||
if (input.target.type === 'local') {
|
||||
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(null, normalized))
|
||||
clearHermesCronOutputRunCountCache()
|
||||
return
|
||||
}
|
||||
await requireExternalAutomationMultiplexer(input.target.connectionId).request(
|
||||
'externalAutomations.create',
|
||||
{
|
||||
provider: input.provider,
|
||||
...normalized
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateExternalAutomation(
|
||||
input: ExternalAutomationUpdateInput
|
||||
): Promise<void> {
|
||||
assertExternalAutomationJobId(input.jobId)
|
||||
const normalized = normalizeHermesCronMutationInput(input)
|
||||
if (input.target.type === 'local') {
|
||||
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(input.jobId, normalized))
|
||||
clearHermesCronOutputRunCountCache(input.jobId)
|
||||
return
|
||||
}
|
||||
await requireExternalAutomationMultiplexer(input.target.connectionId).request(
|
||||
'externalAutomations.update',
|
||||
{
|
||||
provider: input.provider,
|
||||
jobId: input.jobId,
|
||||
...normalized
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function runExternalAutomationAction(
|
||||
input: ExternalAutomationActionInput
|
||||
): Promise<void> {
|
||||
assertExternalAutomationJobId(input.jobId)
|
||||
const command = providerActionCommand(input.provider, input.action)
|
||||
if (input.target.type === 'local') {
|
||||
await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId])
|
||||
if (input.provider === 'hermes') {
|
||||
clearHermesCronOutputRunCountCache(input.jobId)
|
||||
}
|
||||
return
|
||||
}
|
||||
await requireExternalAutomationMultiplexer(input.target.connectionId).request(
|
||||
'externalAutomations.act',
|
||||
{
|
||||
provider: input.provider,
|
||||
action: input.action,
|
||||
jobId: input.jobId
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
|
||||
|
||||
export function requireExternalAutomationMultiplexer(
|
||||
connectionId: string
|
||||
): NonNullable<ReturnType<typeof getActiveMultiplexer>> {
|
||||
const mux = getActiveMultiplexer(connectionId)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
throw new Error(`SSH target "${connectionId}" is not connected.`)
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for a structured JSON-RPC `-32601`, never for error prose.
|
||||
*
|
||||
* A `-32601` object means the peer parsed the request and declined the method,
|
||||
* so it is positive evidence the connection is healthy. Matching the words
|
||||
* "method not found" instead would let a genuinely broken relay be reported as
|
||||
* a missing capability, hiding a dead connection behind a capability notice.
|
||||
*/
|
||||
export function isRelayMethodNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound
|
||||
)
|
||||
}
|
||||
|
||||
export function externalAutomationRelayErrorMessage(error: unknown): string {
|
||||
if (isRelayMethodNotFoundError(error)) {
|
||||
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
ExternalAutomationProvider,
|
||||
ExternalAutomationRunsInput,
|
||||
ExternalAutomationRunsPage
|
||||
} from '../../shared/automations-types'
|
||||
import {
|
||||
EXTERNAL_AUTOMATION_SCOPE_CODES,
|
||||
ExternalAutomationScopeError
|
||||
} from '../../shared/external-automation-scope'
|
||||
import { mapHermesJobs } from './external-job-mappers'
|
||||
import { assertExternalAutomationJobId } from './external-manager-job-id'
|
||||
import {
|
||||
isRelayMethodNotFoundError,
|
||||
requireExternalAutomationMultiplexer
|
||||
} from './external-manager-relay'
|
||||
import { readHermesCronOutputRunsPage } from './hermes-cron-output'
|
||||
|
||||
/**
|
||||
* Runs the relay's paged runs call, translating "no such method" into a code the
|
||||
* renderer can act on.
|
||||
*
|
||||
* The structured code exists only here: Electron IPC preserves nothing but the
|
||||
* message, so a renderer that re-derived this from the host's prose would be
|
||||
* guessing. Translating at the boundary that can still see the code is what lets
|
||||
* the caller fall back to job-carried runs without matching free text.
|
||||
*/
|
||||
async function requestRemoteExternalRuns(
|
||||
connectionId: string,
|
||||
params: { provider: ExternalAutomationProvider; jobId: string; page: number; pageSize: number }
|
||||
): Promise<{ total?: number; runs?: unknown[] }> {
|
||||
try {
|
||||
return (await requireExternalAutomationMultiplexer(connectionId).request(
|
||||
'externalAutomations.runs',
|
||||
params
|
||||
)) as {
|
||||
total?: number
|
||||
runs?: unknown[]
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRelayMethodNotFoundError(error)) {
|
||||
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function listExternalAutomationRuns(
|
||||
input: ExternalAutomationRunsInput
|
||||
): Promise<ExternalAutomationRunsPage> {
|
||||
assertExternalAutomationJobId(input.jobId)
|
||||
const page = Number.isFinite(input.page) ? Math.max(1, Math.floor(input.page)) : 1
|
||||
const pageSize = Number.isFinite(input.pageSize)
|
||||
? Math.min(100, Math.max(1, Math.floor(input.pageSize)))
|
||||
: 25
|
||||
const identity = {
|
||||
managerId: input.managerId,
|
||||
provider: input.provider,
|
||||
target: input.target,
|
||||
jobId: input.jobId,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
if (input.provider !== 'hermes') {
|
||||
return { ...identity, total: 0, runs: [] }
|
||||
}
|
||||
const result =
|
||||
input.target.type === 'local'
|
||||
? await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
|
||||
: await requestRemoteExternalRuns(input.target.connectionId, {
|
||||
provider: input.provider,
|
||||
jobId: input.jobId,
|
||||
page,
|
||||
pageSize
|
||||
})
|
||||
return {
|
||||
...identity,
|
||||
total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0,
|
||||
runs:
|
||||
mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? []
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,16 @@
|
||||
/* eslint-disable max-lines -- Why: external automation discovery, pagination,
|
||||
* and lifecycle routing share provider/target validation and remote relay fallbacks. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import type {
|
||||
ExternalAutomationAction,
|
||||
ExternalAutomationActionInput,
|
||||
ExternalAutomationCreateInput,
|
||||
ExternalAutomationManager,
|
||||
ExternalAutomationProvider,
|
||||
ExternalAutomationRunsInput,
|
||||
ExternalAutomationRunsPage,
|
||||
ExternalAutomationUpdateInput
|
||||
ExternalAutomationRunsPage
|
||||
} from '../../shared/automations-types'
|
||||
import {
|
||||
EXTERNAL_AUTOMATION_SCOPE_CODES,
|
||||
ExternalAutomationScopeError,
|
||||
type ScopedExternalAutomationRequest,
|
||||
type ScopedExternalManagerActionRequest,
|
||||
type ScopedExternalManagerCreateRequest,
|
||||
type ScopedExternalManagerListRequest,
|
||||
type ScopedExternalManagerMutationFields,
|
||||
type ScopedExternalManagerRunsRequest,
|
||||
type ScopedExternalManagerUpdateRequest
|
||||
import type {
|
||||
ScopedExternalAutomationRequest,
|
||||
ScopedExternalManagerActionRequest,
|
||||
ScopedExternalManagerCreateRequest,
|
||||
ScopedExternalManagerListRequest,
|
||||
ScopedExternalManagerMutationFields,
|
||||
ScopedExternalManagerRunsRequest,
|
||||
ScopedExternalManagerUpdateRequest
|
||||
} from '../../shared/external-automation-scope'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
import {
|
||||
resolveExternalAutomationScope,
|
||||
type DesktopSshTargetRegistry,
|
||||
@@ -40,423 +22,19 @@ import {
|
||||
type ExternalAutomationManagerCacheEntry
|
||||
} from './external-automation-manager-cache'
|
||||
import type { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler'
|
||||
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
|
||||
import { listLocalManager, listRemoteManager } from './external-manager-discovery'
|
||||
import {
|
||||
clearHermesCronOutputRunCountCache,
|
||||
readHermesCronOutputRunsPage
|
||||
} from './hermes-cron-output'
|
||||
createExternalAutomation,
|
||||
runExternalAutomationAction,
|
||||
updateExternalAutomation
|
||||
} from './external-manager-mutations'
|
||||
import { listExternalAutomationRuns } from './external-manager-runs'
|
||||
|
||||
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
|
||||
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
|
||||
const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000
|
||||
const PROVIDER_LABELS: Record<ExternalAutomationProvider, string> = {
|
||||
hermes: 'Hermes',
|
||||
openclaw: 'OpenClaw'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function runLocalProviderCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeoutMs: number; timeoutMessage: string }
|
||||
): Promise<void> {
|
||||
return runProcess({ program: command, args, timeoutMs: options.timeoutMs }).then((result) => {
|
||||
if (result.timedOut) {
|
||||
throw new Error(options.timeoutMessage)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || `Command exited with code ${result.code ?? 'unknown'}.`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function isCommandOnPath(command: string): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
// Why: these probes run while loading Automations; a wedged PATH shim must
|
||||
// not keep the list IPC pending forever.
|
||||
await runLocalProviderCommand(finder, [command], {
|
||||
timeoutMs: LOCAL_COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
timeoutMessage: `Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Why: local automation mutations back UI actions; a wedged CLI must not keep
|
||||
// create/edit/run/delete pending after Node signals a timeout.
|
||||
function runLocalAutomationCommand(command: string, args: string[]): Promise<void> {
|
||||
return runLocalProviderCommand(command, args, {
|
||||
timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS,
|
||||
timeoutMessage: `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.`
|
||||
})
|
||||
}
|
||||
|
||||
async function readLocalHermesJobs(): Promise<unknown[]> {
|
||||
if (!existsSync(HERMES_JOBS_FILE)) {
|
||||
return []
|
||||
}
|
||||
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(content) as unknown
|
||||
const jobs = Array.isArray(parsed)
|
||||
? parsed
|
||||
: isRecord(parsed) && Array.isArray(parsed.jobs)
|
||||
? parsed.jobs
|
||||
: []
|
||||
return Promise.all(
|
||||
jobs.map(async (job) => {
|
||||
if (!isRecord(job)) {
|
||||
return job
|
||||
}
|
||||
const jobId = typeof job.id === 'string' ? job.id : null
|
||||
if (!jobId) {
|
||||
return job
|
||||
}
|
||||
const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 })
|
||||
return {
|
||||
...job,
|
||||
run_count: runsPage.total,
|
||||
runs: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function readLocalOpenClawJobs(): Promise<unknown[]> {
|
||||
if (!existsSync(OPENCLAW_JOBS_FILE)) {
|
||||
return []
|
||||
}
|
||||
const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(content) as unknown
|
||||
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
|
||||
}
|
||||
|
||||
function mapProviderJobs(
|
||||
provider: ExternalAutomationProvider,
|
||||
managerId: string,
|
||||
jobs: unknown[]
|
||||
): ExternalAutomationManager['jobs'] {
|
||||
return provider === 'hermes' ? mapHermesJobs(managerId, jobs) : mapOpenClawJobs(managerId, jobs)
|
||||
}
|
||||
|
||||
async function listLocalManager(
|
||||
provider: ExternalAutomationProvider
|
||||
): Promise<ExternalAutomationManager | null> {
|
||||
const [availableResult, jobsResult] = await Promise.allSettled([
|
||||
isCommandOnPath(provider),
|
||||
provider === 'hermes' ? readLocalHermesJobs() : readLocalOpenClawJobs()
|
||||
])
|
||||
const available = availableResult.status === 'fulfilled' && availableResult.value
|
||||
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
|
||||
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
|
||||
if (!available && jobs.length === 0 && !readError) {
|
||||
return null
|
||||
}
|
||||
const managerId = `${provider}:local`
|
||||
const providerLabel = PROVIDER_LABELS[provider]
|
||||
return {
|
||||
id: managerId,
|
||||
provider,
|
||||
label: `${providerLabel} on this computer`,
|
||||
targetLabel: 'this computer',
|
||||
target: { type: 'local' },
|
||||
status: readError ? 'unavailable' : 'available',
|
||||
error:
|
||||
readError ??
|
||||
(available
|
||||
? null
|
||||
: `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`),
|
||||
canManage: !readError && available,
|
||||
jobs: mapProviderJobs(provider, managerId, jobs)
|
||||
}
|
||||
}
|
||||
|
||||
function requireMultiplexer(
|
||||
connectionId: string
|
||||
): NonNullable<ReturnType<typeof getActiveMultiplexer>> {
|
||||
const mux = getActiveMultiplexer(connectionId)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
throw new Error(`SSH target "${connectionId}" is not connected.`)
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for a structured JSON-RPC `-32601`, never for error prose.
|
||||
*
|
||||
* A `-32601` object means the peer parsed the request and declined the method,
|
||||
* so it is positive evidence the connection is healthy. Matching the words
|
||||
* "method not found" instead would let a genuinely broken relay be reported as
|
||||
* a missing capability, hiding a dead connection behind a capability notice.
|
||||
*/
|
||||
function isRelayMethodNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound
|
||||
)
|
||||
}
|
||||
|
||||
function remoteRelayErrorMessage(error: unknown): string {
|
||||
if (isRelayMethodNotFoundError(error)) {
|
||||
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
async function listRemoteManager(
|
||||
target: SshTarget,
|
||||
provider: ExternalAutomationProvider
|
||||
): Promise<ExternalAutomationManager> {
|
||||
const providerLabel = PROVIDER_LABELS[provider]
|
||||
const managerProviderId = `${provider}:ssh:${target.id}`
|
||||
const mux = getActiveMultiplexer(target.id)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
return {
|
||||
id: managerProviderId,
|
||||
provider,
|
||||
label: `${providerLabel} on ${target.label}`,
|
||||
targetLabel: target.label,
|
||||
target: { type: 'ssh', connectionId: target.id },
|
||||
status: 'unavailable',
|
||||
error: 'SSH target is not connected.',
|
||||
canManage: false,
|
||||
jobs: []
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = (await mux.request('externalAutomations.list', { provider })) as {
|
||||
jobs?: unknown[]
|
||||
hermesAvailable?: boolean
|
||||
openclawAvailable?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
const commandAvailable =
|
||||
provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true
|
||||
const readError = result.error ?? null
|
||||
return {
|
||||
id: managerProviderId,
|
||||
provider,
|
||||
label: `${providerLabel} on ${target.label}`,
|
||||
targetLabel: target.label,
|
||||
target: { type: 'ssh', connectionId: target.id },
|
||||
status: readError ? 'unavailable' : 'available',
|
||||
error:
|
||||
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
|
||||
canManage: !readError && commandAvailable,
|
||||
jobs: mapProviderJobs(provider, managerProviderId, result.jobs ?? [])
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: managerProviderId,
|
||||
provider,
|
||||
label: `${providerLabel} on ${target.label}`,
|
||||
targetLabel: target.label,
|
||||
target: { type: 'ssh', connectionId: target.id },
|
||||
status: 'unavailable',
|
||||
error: remoteRelayErrorMessage(error),
|
||||
canManage: false,
|
||||
jobs: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the relay's paged runs call, translating "no such method" into a code the
|
||||
* renderer can act on.
|
||||
*
|
||||
* The structured code exists only here: Electron IPC preserves nothing but the
|
||||
* message, so a renderer that re-derived this from the host's prose would be
|
||||
* guessing. Translating at the boundary that can still see the code is what lets
|
||||
* the caller fall back to job-carried runs without matching free text.
|
||||
*/
|
||||
async function requestRemoteExternalRuns(
|
||||
connectionId: string,
|
||||
params: { provider: ExternalAutomationProvider; jobId: string; page: number; pageSize: number }
|
||||
): Promise<{ total?: number; runs?: unknown[] }> {
|
||||
try {
|
||||
return (await requireMultiplexer(connectionId).request('externalAutomations.runs', params)) as {
|
||||
total?: number
|
||||
runs?: unknown[]
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRelayMethodNotFoundError(error)) {
|
||||
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function listExternalAutomationRuns(
|
||||
input: ExternalAutomationRunsInput
|
||||
): Promise<ExternalAutomationRunsPage> {
|
||||
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
|
||||
throw new Error('Invalid external automation job ID.')
|
||||
}
|
||||
const page = Number.isFinite(input.page) ? Math.max(1, Math.floor(input.page)) : 1
|
||||
const pageSize = Number.isFinite(input.pageSize)
|
||||
? Math.min(100, Math.max(1, Math.floor(input.pageSize)))
|
||||
: 25
|
||||
const identity = {
|
||||
managerId: input.managerId,
|
||||
provider: input.provider,
|
||||
target: input.target,
|
||||
jobId: input.jobId,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
if (input.provider !== 'hermes') {
|
||||
return { ...identity, total: 0, runs: [] }
|
||||
}
|
||||
const result =
|
||||
input.target.type === 'local'
|
||||
? await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
|
||||
: await requestRemoteExternalRuns(input.target.connectionId, {
|
||||
provider: input.provider,
|
||||
jobId: input.jobId,
|
||||
page,
|
||||
pageSize
|
||||
})
|
||||
return {
|
||||
...identity,
|
||||
total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0,
|
||||
runs:
|
||||
mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? []
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_ACTION_COMMANDS: Record<
|
||||
ExternalAutomationProvider,
|
||||
Record<ExternalAutomationAction, string>
|
||||
> = {
|
||||
hermes: { pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' },
|
||||
openclaw: { pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' }
|
||||
}
|
||||
|
||||
/** Fails closed like its neighbours: an unlisted or inherited key is not an action. */
|
||||
function providerActionCommand(
|
||||
provider: ExternalAutomationProvider,
|
||||
action: ExternalAutomationAction
|
||||
): string {
|
||||
const commands = Object.hasOwn(PROVIDER_ACTION_COMMANDS, provider)
|
||||
? PROVIDER_ACTION_COMMANDS[provider]
|
||||
: null
|
||||
const command = commands && Object.hasOwn(commands, action) ? commands[action] : null
|
||||
if (typeof command !== 'string') {
|
||||
throw new Error('Unsupported external automation action.')
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): {
|
||||
name: string
|
||||
prompt: string
|
||||
schedule: string
|
||||
workdir: string | null
|
||||
} {
|
||||
if (input.provider !== 'hermes') {
|
||||
throw new Error('Only Hermes cron creation and editing are supported.')
|
||||
}
|
||||
const name = input.name.trim()
|
||||
const prompt = input.prompt.trim()
|
||||
const schedule = input.schedule.trim()
|
||||
const workdir = input.workdir?.trim() || null
|
||||
if (!prompt) {
|
||||
throw new Error('Hermes cron requires a prompt.')
|
||||
}
|
||||
if (!schedule) {
|
||||
throw new Error('Hermes cron requires a schedule.')
|
||||
}
|
||||
return {
|
||||
name: name || prompt.slice(0, 50).trim() || 'Hermes cron',
|
||||
prompt,
|
||||
schedule,
|
||||
workdir
|
||||
}
|
||||
}
|
||||
|
||||
/** Edit args when `jobId` is given, create args otherwise; both are argv, never a shell string. */
|
||||
function hermesCronMutationArgs(
|
||||
jobId: string | null,
|
||||
input: { name: string; prompt: string; schedule: string; workdir: string | null }
|
||||
): string[] {
|
||||
const args = jobId
|
||||
? ['cron', 'edit', jobId, '--schedule', input.schedule, '--prompt', input.prompt]
|
||||
: ['cron', 'create', input.schedule, input.prompt]
|
||||
args.push('--name', input.name)
|
||||
if (!jobId) {
|
||||
args.push('--deliver', 'local')
|
||||
}
|
||||
if (input.workdir) {
|
||||
args.push('--workdir', input.workdir)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
export async function createExternalAutomation(
|
||||
input: ExternalAutomationCreateInput
|
||||
): Promise<void> {
|
||||
const normalized = normalizeHermesCronMutationInput(input)
|
||||
if (input.target.type === 'local') {
|
||||
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(null, normalized))
|
||||
clearHermesCronOutputRunCountCache()
|
||||
return
|
||||
}
|
||||
await requireMultiplexer(input.target.connectionId).request('externalAutomations.create', {
|
||||
provider: input.provider,
|
||||
...normalized
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExternalAutomation(
|
||||
input: ExternalAutomationUpdateInput
|
||||
): Promise<void> {
|
||||
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
|
||||
throw new Error('Invalid external automation job ID.')
|
||||
}
|
||||
const normalized = normalizeHermesCronMutationInput(input)
|
||||
if (input.target.type === 'local') {
|
||||
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(input.jobId, normalized))
|
||||
clearHermesCronOutputRunCountCache(input.jobId)
|
||||
return
|
||||
}
|
||||
await requireMultiplexer(input.target.connectionId).request('externalAutomations.update', {
|
||||
provider: input.provider,
|
||||
jobId: input.jobId,
|
||||
...normalized
|
||||
})
|
||||
}
|
||||
|
||||
export async function runExternalAutomationAction(
|
||||
input: ExternalAutomationActionInput
|
||||
): Promise<void> {
|
||||
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
|
||||
throw new Error('Invalid external automation job ID.')
|
||||
}
|
||||
const command = providerActionCommand(input.provider, input.action)
|
||||
if (input.target.type === 'local') {
|
||||
await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId])
|
||||
if (input.provider === 'hermes') {
|
||||
clearHermesCronOutputRunCountCache(input.jobId)
|
||||
}
|
||||
return
|
||||
}
|
||||
await requireMultiplexer(input.target.connectionId).request('externalAutomations.act', {
|
||||
provider: input.provider,
|
||||
action: input.action,
|
||||
jobId: input.jobId
|
||||
})
|
||||
export {
|
||||
createExternalAutomation,
|
||||
listExternalAutomationRuns,
|
||||
runExternalAutomationAction,
|
||||
updateExternalAutomation
|
||||
}
|
||||
|
||||
export type ScopedExternalAutomationDeps = {
|
||||
|
||||
Reference in New Issue
Block a user