Remove Hermes automation support (#28519)

* Remove external cron history from the automation UI

* Simplify the automation editor

* Remove Hermes cron discovery and management
This commit is contained in:
Jinjing
2026-10-14 11:36:43 -07:00
committed by GitHub
parent 52d9dd2fd7
commit 8644ade7fc
88 changed files with 505 additions and 4728 deletions
@@ -1,133 +0,0 @@
#!/usr/bin/env node
import assert from 'node:assert/strict'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, basename } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
const root = fileURLToPath(new URL('../..', import.meta.url))
const fixture = await mkdtemp(join(tmpdir(), 'orca-hermes-correlation-'))
const baselineDirectory = process.argv[2]
const key = (seconds) =>
new Date(Date.UTC(2026, 0, 1) + seconds * 1000)
.toISOString()
.replace(/[-:]/g, '')
.replace('T', '_')
.slice(0, 15)
try {
for (const host of ['native']) {
const entry = 'src/main/automations/hermes-cron-run-content.ts'
const readers = []
for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) {
const bundle = join(fixture, `${host}-${mode}.cjs`)
await build({
entryPoints: [join(root, entry)],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: bundle,
plugins:
mode === 'baseline'
? [
{
name: 'baseline-correlation',
setup(builder) {
builder.onLoad(
{ filter: /hermes-(cron-run-content|run-correlation)\.ts$/ },
async (args) => ({
contents: await readFile(
join(baselineDirectory, basename(args.path)),
'utf8'
),
loader: 'ts'
})
)
}
}
]
: []
})
readers.push({ mode, ...createRequire(import.meta.url)(bundle) })
}
if (readers.length === 2) {
let seed = 92817
const random = (max) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return seed % max
}
const pool = [
null,
'',
'invalid',
'20260101_000000',
'20260101_000200',
'20260102_000000',
'20260103_000000',
'20260101_240000'
]
for (let trial = 0; trial < 200; trial++) {
const sessions = Array.from({ length: random(70) }, (_, i) => ({
kind: 'session',
id: `session-${i}`,
job_id: 'job',
run_at: null,
run_key: pool[random(pool.length)],
output_content: `session ${i}`
}))
const outputs = Array.from({ length: random(70) }, (_, i) => ({
kind: 'output',
id: `output-${i}`,
job_id: 'job',
run_at: null,
run_key: pool[random(pool.length)],
output_path: 'unused',
output_content: `output ${i}`
}))
for (const method of [
'mergeHermesOutputAndSessionRunRefs',
'mergeHermesOutputAndSessionRuns'
]) {
assert.deepEqual(
readers[1][method](outputs, sessions),
readers[0][method](outputs, sessions)
)
}
}
console.log(JSON.stringify({ host, randomizedParityCases: 400 }))
}
for (const runs of [100, 1000, 5000]) {
const sessions = Array.from({ length: runs }, (_, i) => ({
kind: 'session',
id: `session-${i}`,
job_id: 'job',
run_at: null,
run_key: key(i * 3600)
})).toReversed()
const outputs = Array.from({ length: runs }, (_, i) => ({
kind: 'output',
id: `output-${i}`,
job_id: 'job',
run_at: null,
run_key: key(i * 3600 + 120),
output_path: 'unused'
}))
let expected
for (const reader of [...readers, ...readers.toReversed()]) {
const start = performance.now()
const result = reader.mergeHermesOutputAndSessionRunRefs(outputs, sessions)
const durationMs = performance.now() - start
assert.equal(result.length, runs)
result.forEach((row, i) => assert.equal(row.session.id, `session-${i}`))
if (expected) {
assert.deepEqual(result, expected)
}
expected = result
console.log(JSON.stringify({ host, mode: reader.mode, runs, durationMs }))
}
}
}
} finally {
await rm(fixture, { recursive: true, force: true })
}
@@ -10,7 +10,7 @@ import type { ExternalAutomationManager } from '../../shared/automations-types'
function manager(id: string): ExternalAutomationManager {
return {
id,
provider: 'hermes',
provider: 'openclaw',
label: id,
targetLabel: 'this computer',
target: { type: 'local' },
@@ -29,69 +29,68 @@ describe('ExternalAutomationManagerCache', () => {
const cache = new ExternalAutomationManagerCache()
for (let index = 0; index < MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES + 4; index += 1) {
cache.write({ ownerKey: `owner-${index}`, provider: 'hermes' }, null)
cache.write({ ownerKey: `owner-${index}`, provider: 'openclaw' }, null)
}
expect(cache.size).toBe(MAX_EXTERNAL_AUTOMATION_MANAGER_CACHE_ENTRIES)
})
it('keys entries per owner and per provider', () => {
it('keeps each owner’s manager and failure separate', () => {
const cache = new ExternalAutomationManagerCache()
const failedOwner = 'owner:desktop:ssh:target-b:1'
cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local'))
cache.writeFailure({ ownerKey: selfOwner, provider: 'openclaw' }, new Error('no openclaw'))
cache.write({ ownerKey: sshOwner, provider: 'hermes' }, manager('hermes:ssh:target-a'))
cache.write({ ownerKey: selfOwner, provider: 'openclaw' }, manager('openclaw:local'))
cache.writeFailure({ ownerKey: failedOwner, provider: 'openclaw' }, new Error('no openclaw'))
cache.write({ ownerKey: sshOwner, provider: 'openclaw' }, manager('openclaw:ssh:target-a'))
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })?.manager?.id).toBe(
'hermes:local'
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })?.manager?.id).toBe(
'openclaw:local'
)
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })?.error).toBe('no openclaw')
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })?.error).toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'hermes' })?.manager?.id).toBe(
'hermes:ssh:target-a'
expect(cache.read({ ownerKey: failedOwner, provider: 'openclaw' })?.error).toBe('no openclaw')
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })?.error).toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'openclaw' })?.manager?.id).toBe(
'openclaw:ssh:target-a'
)
})
it('drops only the named host when a mutation invalidates it', () => {
const cache = new ExternalAutomationManagerCache()
cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local'))
cache.write({ ownerKey: sshOwner, provider: 'hermes' }, manager('hermes:ssh:target-a'))
cache.write({ ownerKey: selfOwner, provider: 'openclaw' }, manager('openclaw:local'))
cache.write({ ownerKey: sshOwner, provider: 'openclaw' }, manager('openclaw:ssh:target-a'))
cache.invalidateOwner(sshOwner)
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).not.toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'hermes' })).toBeNull()
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })).not.toBeNull()
expect(cache.read({ ownerKey: sshOwner, provider: 'openclaw' })).toBeNull()
})
it('treats an entry past its TTL as absent', () => {
let now = 1_000
const cache = new ExternalAutomationManagerCache({ ttlMs: 100, now: () => now })
cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local'))
cache.write({ ownerKey: selfOwner, provider: 'openclaw' }, manager('openclaw:local'))
now += 100
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).not.toBeNull()
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })).not.toBeNull()
now += 1
expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).toBeNull()
expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })).toBeNull()
})
it('prunes expired entries when the cache is accessed', () => {
let now = 1_000
const cache = new ExternalAutomationManagerCache({ ttlMs: 100, now: () => now })
for (let index = 0; index < 10; index += 1) {
cache.write({ ownerKey: `owner-${index}`, provider: 'hermes' }, manager(String(index)))
cache.write({ ownerKey: `owner-${index}`, provider: 'openclaw' }, manager(String(index)))
}
now += 101
expect(cache.read({ ownerKey: 'unrelated', provider: 'hermes' })).toBeNull()
expect(cache.read({ ownerKey: 'unrelated', provider: 'openclaw' })).toBeNull()
expect(cache.size).toBe(0)
})
it('serves a fresh entry without reloading, and reloads on refresh', async () => {
const cache = new ExternalAutomationManagerCache()
const load = vi.fn(() => Promise.resolve(manager('hermes:local')))
const key = { ownerKey: selfOwner, provider: 'hermes' }
const load = vi.fn(() => Promise.resolve(manager('openclaw:local')))
const key = { ownerKey: selfOwner, provider: 'openclaw' }
await cache.resolve(key, load)
await cache.resolve(key, load)
@@ -104,7 +103,7 @@ describe('ExternalAutomationManagerCache', () => {
it('records a load failure as error state rather than throwing', async () => {
const cache = new ExternalAutomationManagerCache()
const entry = await cache.resolve({ ownerKey: sshOwner, provider: 'hermes' }, () =>
const entry = await cache.resolve({ ownerKey: sshOwner, provider: 'openclaw' }, () =>
Promise.reject(new Error('relay refused'))
)
@@ -114,15 +113,15 @@ describe('ExternalAutomationManagerCache', () => {
it('propagates cancellation and leaves the previous entry untouched', async () => {
const cache = new ExternalAutomationManagerCache()
const key = { ownerKey: sshOwner, provider: 'hermes' }
cache.write(key, manager('hermes:ssh:target-a'))
const key = { ownerKey: sshOwner, provider: 'openclaw' }
cache.write(key, manager('openclaw:ssh:target-a'))
await expect(
cache.resolve(key, () => Promise.reject(new ExternalAutomationProbeCancelledError()), {
refresh: true
})
).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
expect(cache.read(key)?.manager?.id).toBe('hermes:ssh:target-a')
expect(cache.read(key)?.manager?.id).toBe('openclaw:ssh:target-a')
})
})
@@ -37,13 +37,13 @@ function desktopSsh(targetId: string, targetGeneration: number): AutomationOwner
describe('resolveExternalAutomationScope', () => {
it('resolves desktop + self to the local target', () => {
const scope = resolveExternalAutomationScope(
{ owner: desktopSelf, provider: 'hermes' },
{ owner: desktopSelf, provider: 'openclaw' },
registry([])
)
expect(scope.target).toEqual({ type: 'local' })
expect(scope.sshTarget).toBeNull()
expect(scope.managerId).toBe('hermes:local')
expect(scope.managerId).toBe('openclaw:local')
})
it('resolves a desktop SSH host whose registration generation still matches', () => {
@@ -64,13 +64,13 @@ describe('resolveExternalAutomationScope', () => {
expect(() =>
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'hermes' },
{ owner: desktopSsh('target-a', 3), provider: 'openclaw' },
registry([target])
)
).toThrow(ExternalAutomationScopeError)
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'hermes' },
{ owner: desktopSsh('target-a', 3), provider: 'openclaw' },
registry([target])
)
} catch (error) {
@@ -88,7 +88,7 @@ describe('resolveExternalAutomationScope', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 3), provider: 'hermes' },
{ owner: desktopSsh('target-a', 3), provider: 'openclaw' },
registry([target])
)
expect.unreachable('hidden target must not resolve')
@@ -102,7 +102,7 @@ describe('resolveExternalAutomationScope', () => {
it('fails closed with the host-changed conflict on a stale generation', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 2), provider: 'hermes' },
{ owner: desktopSsh('target-a', 2), provider: 'openclaw' },
registry([sshTarget({ generation: 3 })])
)
expect.unreachable('stale generation must not resolve')
@@ -115,7 +115,7 @@ describe('resolveExternalAutomationScope', () => {
it('fails closed when the registration carries no usable generation', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('target-a', 1), provider: 'hermes' },
{ owner: desktopSsh('target-a', 1), provider: 'openclaw' },
registry([sshTarget({ generation: undefined })])
)
expect.unreachable('unstamped registration must not resolve')
@@ -127,7 +127,7 @@ describe('resolveExternalAutomationScope', () => {
it('reports a removed SSH host as a target-removed conflict', () => {
try {
resolveExternalAutomationScope(
{ owner: desktopSsh('gone', 1), provider: 'hermes' },
{ owner: desktopSsh('gone', 1), provider: 'openclaw' },
registry([sshTarget()])
)
expect.unreachable('missing target must not resolve')
@@ -143,7 +143,7 @@ describe('resolveExternalAutomationScope', () => {
}
try {
resolveExternalAutomationScope({ owner: runtimeOwner, provider: 'hermes' }, registry([]))
resolveExternalAutomationScope({ owner: runtimeOwner, provider: 'openclaw' }, registry([]))
expect.unreachable('runtime authority must not resolve')
} catch (error) {
expect((error as ExternalAutomationScopeError).code).toBe(
@@ -47,8 +47,8 @@ describe('ExternalAutomationProbeScheduler', () => {
const run = vi.fn(() => Promise.resolve('manager'))
const [first, second] = await Promise.all([
scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }),
scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
scheduler.schedule({ key: 'openclaw@self', scopeKey: 'owner:desktop:self', run }),
scheduler.schedule({ key: 'openclaw@self', scopeKey: 'owner:desktop:self', run })
])
expect(run).toHaveBeenCalledTimes(1)
@@ -61,7 +61,7 @@ describe('ExternalAutomationProbeScheduler', () => {
const run = vi.fn(() => Promise.resolve('manager'))
const release = scheduler.beginPriorityWork()
const probe = scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
const probe = scheduler.schedule({ key: 'openclaw@self', scopeKey: 'owner:desktop:self', run })
expect(run).not.toHaveBeenCalled()
expect(scheduler.queued).toBe(1)
@@ -78,7 +78,7 @@ describe('ExternalAutomationProbeScheduler', () => {
const keptRun = vi.fn(() => Promise.resolve('local'))
const inFlight = scheduler.schedule({
key: 'hermes@ssh',
key: 'openclaw@ssh',
scopeKey: 'owner:desktop:ssh:target-a:3',
run: () => never.promise
})
@@ -88,7 +88,7 @@ describe('ExternalAutomationProbeScheduler', () => {
run: queuedRun
})
const kept = scheduler.schedule({
key: 'hermes@self',
key: 'openclaw@self',
scopeKey: 'owner:desktop:self',
run: keptRun
})
@@ -110,7 +110,7 @@ describe('ExternalAutomationProbeScheduler', () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
const never = deferred<string>()
const probe = scheduler.schedule({
key: 'hermes@self',
key: 'openclaw@self',
scopeKey: 'owner:desktop:self',
run: () => never.promise
})
@@ -127,10 +127,10 @@ describe('ExternalAutomationProbeScheduler', () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 2 })
const never = deferred<string>()
const run = vi.fn(() => never.promise)
const first = scheduler.schedule({ key: 'hermes@self', scopeKey: 'self', run })
const first = scheduler.schedule({ key: 'openclaw@self', scopeKey: 'self', run })
scheduler.cancelAll()
const second = scheduler.schedule({ key: 'hermes@self', scopeKey: 'self', run })
const second = scheduler.schedule({ key: 'openclaw@self', scopeKey: 'self', run })
await expect(first).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
await expect(second).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
@@ -94,50 +94,6 @@ function mapExternalRuns({
.map(({ run }) => run)
}
function hermesScheduleDisplay(job: ExternalJobRecord): string {
const direct = asString(job.schedule_display)
if (direct) {
return direct
}
const schedule = job.schedule
if (isRecord(schedule)) {
return (
asString(schedule.display) ??
asString(schedule.value) ??
asString(schedule.expr) ??
asString(schedule.run_at) ??
'?'
)
}
return asString(schedule) ?? '?'
}
function hermesRawSchedule(job: ExternalJobRecord): string | null {
const schedule = job.schedule
if (isRecord(schedule)) {
return (
asString(schedule.expr) ??
asString(schedule.value) ??
asString(schedule.display) ??
asString(schedule.run_at)
)
}
return asString(schedule) ?? asString(job.schedule_display)
}
function hermesPromptPreview(job: ExternalJobRecord): string {
const prompt = asString(job.prompt) ?? ''
if (prompt) {
return prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt
}
const script = asString(job.script)
if (script) {
return job.no_agent ? `Script: ${script}` : `Prompt uses script context: ${script}`
}
const skills = Array.isArray(job.skills) ? job.skills.map((skill) => String(skill)) : []
return skills.length > 0 ? `Skills: ${skills.join(', ')}` : ''
}
function openClawScheduleDisplay(job: ExternalJobRecord): string {
const schedule = job.schedule
if (!isRecord(schedule)) {
@@ -175,41 +131,6 @@ function openClawPromptPreview(job: ExternalJobRecord): string {
return text.length > 120 ? `${text.slice(0, 117)}...` : text
}
export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] {
if (!Array.isArray(rawJobs)) {
return []
}
return rawJobs.filter(isRecord).map((job) => {
const id = asString(job.id) ?? 'unknown'
const enabled = job.enabled !== false && job.state !== 'paused'
const preview = hermesPromptPreview(job)
return {
id,
managerId,
provider: 'hermes',
name: (asString(job.name) ?? preview) || id,
schedule: hermesScheduleDisplay(job),
rawSchedule: hermesRawSchedule(job),
enabled,
state: asString(job.state) ?? (enabled ? 'scheduled' : 'paused'),
prompt: asString(job.prompt),
promptPreview: preview,
nextRunAt: asString(job.next_run_at),
lastRunAt: asString(job.last_run_at),
lastStatus: asString(job.last_status),
lastError: asString(job.last_error) ?? asString(job.last_delivery_error),
workdir: asString(job.workdir),
runCount: asNumber(job.run_count) ?? (Array.isArray(job.runs) ? job.runs.length : 0),
runs: mapExternalRuns({
managerId,
provider: 'hermes',
jobId: id,
rawRuns: job.runs
})
}
})
}
export function mapOpenClawJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] {
const jobs = Array.isArray(rawJobs)
? rawJobs
@@ -6,16 +6,11 @@ import type {
ExternalAutomationManager,
ExternalAutomationProvider
} from '../../shared/automations-types'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { readHermesCronOutputRunsPage } from './hermes-cron-output'
import { mapOpenClawJobs } from './external-job-mappers'
import { isExternalAutomationCommandOnPath } from './external-manager-local-command'
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'
}
@@ -23,36 +18,6 @@ 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 []
@@ -62,20 +27,12 @@ async function readLocalOpenClawJobs(): Promise<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()
readLocalOpenClawJobs()
])
const available = availableResult.status === 'fulfilled' && availableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
@@ -98,6 +55,6 @@ export async function listLocalManager(
? null
: `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`),
canManage: !readError && available,
jobs: mapProviderJobs(provider, managerId, jobs)
jobs: mapOpenClawJobs(managerId, jobs)
}
}
@@ -36,7 +36,7 @@ export async function isExternalAutomationCommandOnPath(command: string): Promis
}
// Why: local automation mutations back UI actions; a wedged CLI must not keep
// create/edit/run/delete pending after Node signals a timeout.
// pause/resume/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,
@@ -1,20 +1,16 @@
import type {
ExternalAutomationAction,
ExternalAutomationActionInput,
ExternalAutomationCreateInput,
ExternalAutomationProvider,
ExternalAutomationUpdateInput
ExternalAutomationProvider
} from '../../shared/automations-types'
import { assertExternalAutomationJobId } from './external-manager-job-id'
import { runLocalAutomationCommand } from './external-manager-local-command'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
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' }
}
@@ -33,76 +29,6 @@ function providerActionCommand(
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
}
throw new SshHostNotServedError(input.target.connectionId)
}
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
}
throw new SshHostNotServedError(input.target.connectionId)
}
export async function runExternalAutomationAction(
input: ExternalAutomationActionInput
): Promise<void> {
@@ -110,9 +36,6 @@ export async function runExternalAutomationAction(
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
}
throw new SshHostNotServedError(input.target.connectionId)
@@ -1,39 +0,0 @@
import type {
ExternalAutomationRunsInput,
ExternalAutomationRunsPage
} from '../../shared/automations-types'
import { mapHermesJobs } from './external-job-mappers'
import { assertExternalAutomationJobId } from './external-manager-job-id'
import { readHermesCronOutputRunsPage } from './hermes-cron-output'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-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: [] }
}
if (input.target.type !== 'local') {
throw new SshHostNotServedError(input.target.connectionId)
}
const result = await readHermesCronOutputRunsPage(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 ?? []
}
}
@@ -12,15 +12,25 @@ import {
import type { ExternalAutomationProvider } from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import type * as Fs from 'node:fs'
import type * as Os from 'node:os'
import { join } from 'node:path'
const runProcessMock = vi.hoisted(() => vi.fn())
const existsSyncMock = vi.hoisted(() => vi.fn(() => false))
const existsSyncMock = vi.hoisted(() => vi.fn<(file: Fs.PathLike) => boolean>(() => false))
const readFileMock = vi.hoisted(() => vi.fn())
const writeFileMock = vi.hoisted(() => vi.fn())
const fixtureHome = vi.hoisted(() => 'external-manager-fixture-home')
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
return { ...actual, existsSync: existsSyncMock }
})
vi.mock('node:fs/promises', () => ({ readFile: readFileMock, writeFile: writeFileMock }))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<typeof Os>()),
homedir: () => fixtureHome
}))
function sshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
@@ -66,6 +76,8 @@ beforeEach(() => {
timedOut: false
})
existsSyncMock.mockReturnValue(false)
readFileMock.mockReset()
writeFileMock.mockReset()
})
afterEach(() => {
@@ -73,6 +85,47 @@ afterEach(() => {
})
describe('scoped external automations', () => {
it('discovers only OpenClaw and leaves isolated Hermes cron files untouched', async () => {
const files = new Map([
[join(fixtureHome, '.hermes', 'cron', 'jobs.json'), '[{"id":"hermes-job","enabled":true}]'],
[join(fixtureHome, '.hermes', 'state.db'), 'Hermes session fixture'],
[join(fixtureHome, '.hermes', 'cron', 'executions.db'), 'Hermes execution fixture'],
[join(fixtureHome, '.hermes', 'cron', 'output', 'hermes-job', 'run.md'), '# Output\nDone'],
[join(fixtureHome, '.hermes', 'config.yaml'), 'model: fixture-model'],
[
join(fixtureHome, '.openclaw', 'cron', 'jobs.json'),
'{"jobs":[{"id":"claw-job","name":"OpenClaw job","enabled":true}]}'
]
])
const before = [...files]
existsSyncMock.mockImplementation((file) => files.has(String(file)))
readFileMock.mockImplementation(async (file) => {
const content = files.get(String(file))
if (content === undefined) {
throw new Error('Unexpected fixture read')
}
return content
})
const entry = await buildEngine([]).listManager({ owner: desktopSelf, provider: 'openclaw' })
expect(entry.manager?.jobs.map((job) => job.id)).toEqual(['claw-job'])
expect(readFileMock).toHaveBeenCalledExactlyOnceWith(
join(fixtureHome, '.openclaw', 'cron', 'jobs.json'),
'utf-8'
)
expect(existsSyncMock).toHaveBeenCalledExactlyOnceWith(
join(fixtureHome, '.openclaw', 'cron', 'jobs.json')
)
expect(runProcessMock).toHaveBeenCalledExactlyOnceWith({
program: process.platform === 'win32' ? 'where' : 'which',
args: ['openclaw'],
timeoutMs: 5_000
})
expect(writeFileMock).not.toHaveBeenCalled()
expect([...files]).toEqual(before)
})
// The probe's own timeout, not the caller's: a provider binary that never
// returns must still resolve the entry, or the page waits forever on one host.
it('settles when a local command lookup hangs', async () => {
@@ -84,7 +137,7 @@ describe('scoped external automations', () => {
stderr: '',
timedOut: true
})
const promise = buildEngine([]).listManager({ owner: desktopSelf, provider: 'hermes' })
const promise = buildEngine([]).listManager({ owner: desktopSelf, provider: 'openclaw' })
let settled = false
void promise.finally(() => {
settled = true
@@ -97,13 +150,13 @@ describe('scoped external automations', () => {
it('probes only local managers when Local is selected', async () => {
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
const entry = await engine.listManager({ owner: desktopSelf, provider: 'hermes' })
const entry = await engine.listManager({ owner: desktopSelf, provider: 'openclaw' })
expect(entry.manager?.id).toBe('hermes:local')
expect(entry.manager?.id).toBe('openclaw:local')
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(runProcessMock.mock.calls[0]?.[0]).toEqual({
program: 'which',
args: ['hermes'],
program: process.platform === 'win32' ? 'where' : 'which',
args: ['openclaw'],
timeoutMs: 5_000
})
})
@@ -113,7 +166,7 @@ describe('scoped external automations', () => {
const entry = await engine.listManager({
owner: desktopSsh('target-a', 3),
provider: 'hermes'
provider: 'openclaw'
})
expect(entry).toMatchObject({ manager: null, error: SSH_HOST_NOT_SERVED_MESSAGE })
@@ -124,7 +177,7 @@ describe('scoped external automations', () => {
const engine = buildEngine([sshTarget({ generation: 4 })])
await expect(
engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'hermes' })
engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'openclaw' })
).rejects.toBeInstanceOf(AutomationOwnerConflictError)
expect(runProcessMock).not.toHaveBeenCalled()
})
@@ -135,7 +188,7 @@ describe('scoped external automations', () => {
])
await expect(
engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'hermes' })
engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'openclaw' })
).rejects.toBeInstanceOf(ExternalAutomationScopeError)
expect(runProcessMock).not.toHaveBeenCalled()
})
@@ -149,7 +202,7 @@ describe('scoped external automations', () => {
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 2 },
selector: { kind: 'self' }
},
provider: 'hermes'
provider: 'openclaw'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported)
expect(runProcessMock).not.toHaveBeenCalled()
@@ -174,14 +227,11 @@ describe('scoped external automations', () => {
const engine = buildEngine([sshTarget({ generation: 5 })])
await expect(
engine.update({
engine.runAction({
owner: desktopSsh('target-a', 3),
provider: 'hermes',
provider: 'openclaw',
jobId: 'job-1',
name: 'Nightly',
prompt: 'do the thing',
schedule: '0 2 * * *',
workdir: null
action: 'run'
})
).rejects.toBeInstanceOf(AutomationOwnerConflictError)
expect(runProcessMock).not.toHaveBeenCalled()
@@ -202,27 +252,12 @@ describe('scoped external automations', () => {
const failed = await engine.listManager({
owner: desktopSsh('target-a', 3),
provider: 'hermes'
provider: 'openclaw'
})
const healthy = await engine.listManager({ owner: desktopSelf, provider: 'hermes' })
const healthy = await engine.listManager({ owner: desktopSelf, provider: 'openclaw' })
expect(failed).toMatchObject({ manager: null, error: SSH_HOST_NOT_SERVED_MESSAGE })
expect(healthy.manager?.status).toBe('available')
expect(health.markUnavailable).not.toHaveBeenCalled()
})
it('refuses scoped runs on an unserved SSH host without accepting a target from the caller', async () => {
const engine = buildEngine([sshTarget()])
await expect(
engine.listRuns({
owner: desktopSsh('target-a', 3),
provider: 'hermes',
jobId: 'job-1',
page: 1,
pageSize: 10
})
).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE)
expect(runProcessMock).not.toHaveBeenCalled()
})
})
+19 -238
View File
@@ -1,11 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createExternalAutomation,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
} from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { runExternalAutomationAction } from './external-manager'
import { mapOpenClawJobs } from './external-job-mappers'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
import type {
ExternalAutomationAction,
@@ -41,230 +36,18 @@ afterEach(() => {
vi.useRealTimers()
})
describe('mapHermesJobs', () => {
it('normalizes Hermes cron jobs into external automation rows', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-1',
name: 'Nightly audit',
prompt: 'Audit the repo for risky dependency changes',
schedule_display: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
next_run_at: '2026-05-16T09:00:00Z',
last_run_at: '2026-05-15T09:00:00Z',
last_status: 'ok',
workdir: '/repo'
}
])
expect(jobs).toEqual([
{
id: 'job-1',
managerId: 'hermes:local',
provider: 'hermes',
name: 'Nightly audit',
schedule: '0 9 * * 1-5',
rawSchedule: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
prompt: 'Audit the repo for risky dependency changes',
promptPreview: 'Audit the repo for risky dependency changes',
nextRunAt: '2026-05-16T09:00:00Z',
lastRunAt: '2026-05-15T09:00:00Z',
lastStatus: 'ok',
lastError: null,
workdir: '/repo',
runCount: 0,
runs: []
}
])
})
it('normalizes Hermes output files into run history', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-1',
name: 'Nightly audit',
schedule_display: '0 9 * * 1-5',
runs: [
{
id: 'job-1:2026-05-15_09-00-00.md',
job_id: 'job-1',
run_at: '2026-05-15T09:00:00',
status: 'completed',
output_preview: 'No risky dependency changes.',
output_path: '/home/me/.hermes/cron/output/job-1/2026-05-15_09-00-00.md'
},
{
id: 'job-1:2026-05-14_09-00-00.md',
job_id: 'job-1',
run_at: '2026-05-14T09:00:00',
status: 'failed',
error: 'RuntimeError: missing key'
}
]
}
])
expect(jobs[0].runs).toEqual([
{
id: 'job-1:2026-05-15_09-00-00.md',
managerId: 'hermes:local',
provider: 'hermes',
jobId: 'job-1',
runAt: '2026-05-15T09:00:00',
status: 'completed',
outputPreview: 'No risky dependency changes.',
outputContent: null,
error: null,
outputPath: '/home/me/.hermes/cron/output/job-1/2026-05-15_09-00-00.md'
},
{
id: 'job-1:2026-05-14_09-00-00.md',
managerId: 'hermes:local',
provider: 'hermes',
jobId: 'job-1',
runAt: '2026-05-14T09:00:00',
status: 'failed',
outputPreview: null,
outputContent: null,
error: 'RuntimeError: missing key',
outputPath: null
}
])
})
it('falls back to script and schedule fields for older Hermes records', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-2',
script: 'disk-check.sh',
no_agent: true,
schedule: { display: 'every 30m' },
enabled: false,
state: 'paused',
last_delivery_error: 'home channel missing'
}
])
expect(jobs[0]).toMatchObject({
id: 'job-2',
name: 'Script: disk-check.sh',
schedule: 'every 30m',
enabled: false,
state: 'paused',
promptPreview: 'Script: disk-check.sh',
prompt: null,
rawSchedule: 'every 30m',
lastError: 'home channel missing'
})
})
})
describe('createExternalAutomation', () => {
it('creates local Hermes cron jobs through the CLI', async () => {
await createExternalAutomation({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
name: 'Nightly audit',
prompt: 'Audit the repo',
schedule: '0 9 * * 1-5',
workdir: '/repo'
})
expect(runProcessMock).toHaveBeenCalledWith({
program: 'hermes',
args: [
'cron',
'create',
'0 9 * * 1-5',
'Audit the repo',
'--name',
'Nightly audit',
'--deliver',
'local',
'--workdir',
'/repo'
],
timeoutMs: 30_000
})
})
it('settles when local Hermes cron creation hangs', async () => {
runProcessMock.mockResolvedValue({
code: null,
signal: null,
stdout: '',
stderr: '',
timedOut: true
})
const promise = createExternalAutomation({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
name: 'Nightly audit',
prompt: 'Audit the repo',
schedule: '0 9 * * 1-5',
workdir: null
})
let settled = false
void promise
.catch(() => undefined)
.finally(() => {
settled = true
})
await expect(promise).rejects.toThrow('Local automation command timed out')
expect(settled).toBe(true)
})
it('updates local Hermes cron jobs through the CLI', async () => {
await updateExternalAutomation({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: 'job-1',
name: 'Nightly audit',
prompt: 'Audit the repo',
schedule: '0 10 * * 1-5',
workdir: '/repo'
})
expect(runProcessMock).toHaveBeenCalledWith({
program: 'hermes',
args: [
'cron',
'edit',
'job-1',
'--schedule',
'0 10 * * 1-5',
'--prompt',
'Audit the repo',
'--name',
'Nightly audit',
'--workdir',
'/repo'
],
timeoutMs: 30_000
})
})
})
describe('runExternalAutomationAction', () => {
it('runs local Hermes lifecycle actions through the CLI', async () => {
it('runs local OpenClaw lifecycle actions through the CLI', async () => {
await runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
managerId: 'openclaw:local',
provider: 'openclaw',
target: { type: 'local' },
jobId: 'job-1',
action: 'run'
})
expect(runProcessMock).toHaveBeenCalledWith({
program: 'hermes',
program: 'openclaw',
args: ['cron', 'run', 'job-1'],
timeoutMs: 30_000
})
@@ -273,8 +56,8 @@ describe('runExternalAutomationAction', () => {
it('rejects job IDs that could be parsed as CLI options', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
managerId: 'openclaw:local',
provider: 'openclaw',
target: { type: 'local' },
jobId: '-help',
action: 'run'
@@ -287,8 +70,8 @@ describe('runExternalAutomationAction', () => {
it('rejects an action name that is not in the provider table', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
managerId: 'openclaw:local',
provider: 'openclaw',
target: { type: 'local' },
jobId: 'job-1',
action: 'purge' as ExternalAutomationAction
@@ -301,8 +84,8 @@ describe('runExternalAutomationAction', () => {
it('rejects an action inherited from the prototype chain', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
managerId: 'openclaw:local',
provider: 'openclaw',
target: { type: 'local' },
jobId: 'job-1',
action: 'toString' as unknown as ExternalAutomationAction
@@ -315,7 +98,7 @@ describe('runExternalAutomationAction', () => {
it('rejects a provider that has no action table', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
managerId: 'openclaw:local',
provider: 'shellcat' as ExternalAutomationProvider,
target: { type: 'local' },
jobId: 'job-1',
@@ -341,20 +124,18 @@ describe('runExternalAutomationAction', () => {
timeoutMs: 30_000
})
})
})
describe('listExternalAutomationRuns', () => {
it('refuses an SSH target this process does not serve', async () => {
it('refuses actions on an SSH host without launching locally', async () => {
await expect(
listExternalAutomationRuns({
managerId: 'hermes:ssh:ssh-1',
provider: 'hermes',
runExternalAutomationAction({
managerId: 'openclaw:ssh:ssh-1',
provider: 'openclaw',
target: { type: 'ssh', connectionId: 'ssh-1' },
jobId: 'job-1',
page: 1,
pageSize: 10
action: 'run'
})
).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE)
expect(runProcessMock).not.toHaveBeenCalled()
})
})
+3 -58
View File
@@ -1,15 +1,7 @@
import type {
ExternalAutomationCreateInput,
ExternalAutomationRunsPage
} from '../../shared/automations-types'
import type {
ScopedExternalAutomationRequest,
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerMutationFields,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
ScopedExternalManagerListRequest
} from '../../shared/external-automation-scope'
import {
resolveExternalAutomationScope,
@@ -24,19 +16,9 @@ import {
import type { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler'
import { listLocalManager } from './external-manager-discovery'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import {
createExternalAutomation,
runExternalAutomationAction,
updateExternalAutomation
} from './external-manager-mutations'
import { listExternalAutomationRuns } from './external-manager-runs'
import { runExternalAutomationAction } from './external-manager-mutations'
export {
createExternalAutomation,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
}
export { runExternalAutomationAction }
export type ScopedExternalAutomationDeps = {
registry: DesktopSshTargetRegistry
@@ -48,9 +30,6 @@ export type ScopedExternalAutomations = {
listManager: (
request: ScopedExternalManagerListRequest
) => Promise<ExternalAutomationManagerCacheEntry>
listRuns: (request: ScopedExternalManagerRunsRequest) => Promise<ExternalAutomationRunsPage>
create: (request: ScopedExternalManagerCreateRequest) => Promise<void>
update: (request: ScopedExternalManagerUpdateRequest) => Promise<void>
runAction: (request: ScopedExternalManagerActionRequest) => Promise<void>
}
@@ -64,19 +43,6 @@ export function createScopedExternalAutomations(
): ScopedExternalAutomations {
const resolve = (request: ScopedExternalAutomationRequest): ResolvedExternalAutomationScope =>
resolveExternalAutomationScope(request, deps.registry)
const mutationInput = (
scope: ResolvedExternalAutomationScope,
fields: ScopedExternalManagerMutationFields
): ExternalAutomationCreateInput => ({
managerId: scope.managerId,
provider: scope.provider,
target: scope.target,
name: fields.name,
prompt: fields.prompt,
schedule: fields.schedule,
workdir: fields.workdir
})
return {
async listManager(request) {
const scope = resolve(request)
@@ -95,27 +61,6 @@ export function createScopedExternalAutomations(
{ refresh: request.refresh }
)
},
async listRuns(request) {
const scope = resolve(request)
return await listExternalAutomationRuns({
managerId: scope.managerId,
provider: scope.provider,
target: scope.target,
jobId: request.jobId,
page: request.page,
pageSize: request.pageSize
})
},
async create(request) {
const scope = resolve(request)
await createExternalAutomation(mutationInput(scope, request))
deps.cache.invalidateOwner(scope.ownerKey)
},
async update(request) {
const scope = resolve(request)
await updateExternalAutomation({ ...mutationInput(scope, request), jobId: request.jobId })
deps.cache.invalidateOwner(scope.ownerKey)
},
async runAction(request) {
const scope = resolve(request)
await runExternalAutomationAction({
@@ -65,6 +65,20 @@ function launch(runtime: ReturnType<typeof harness>, overrides: Record<string, u
}
describe('headless automation agent launch', () => {
it('launches Hermes through the ordinary agent contract', async () => {
const runtime = harness()
const launched = await launch(runtime, { agentId: 'hermes' })
expect(runtime.launchAgentTerminal).toHaveBeenCalledWith('id:wt-1', {
agent: 'hermes',
prompt: 'fix the flaky test',
title: 'Nightly'
})
expect(runtime.deliverStartupFollowup).not.toHaveBeenCalled()
expect(launched.terminalHandle).toBe('term_existing')
})
it('starts an argv agent in an existing workspace with the prompt on its command', async () => {
const runtime = harness()
@@ -1,284 +0,0 @@
export const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/
const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/
export const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000
export const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024
export const FULL_SESSION_LOG_HEADING = '## Full session log'
export const REFERENCED_LOG_HEADING = '## Latest log file'
const RUN_PREVIEW_LIMIT = 180
export const LATEST_LOG_PATH_PATTERN =
/\bLatest log path:\s*(?<path>(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
export function runAtFromHermesOutputFile(filename: string): string | null {
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
if (!match) {
return null
}
const [, year, month, day, hour, minute, second] = match
return `${year}-${month}-${day}T${hour}:${minute}:${second}`
}
export function runKeyFromHermesOutputFile(filename: string): string | null {
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
if (!match) {
return null
}
const [, year, month, day, hour, minute, second] = match
return `${year}${month}${day}_${hour}${minute}${second}`
}
export function runAtFromUnixSeconds(value: unknown): string | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
const date = new Date(value * 1000)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
export function sortableTimeFromRunKey(runKey: string | null): number {
if (!runKey) {
return Number.NaN
}
const match = HERMES_RUN_KEY_PATTERN.exec(runKey)
if (!match) {
return Number.NaN
}
const [, year, month, day, hour, minute, second] = match
return Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
)
}
export function escapeSqlLike(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
type ContentRange = {
start: number
end: number
}
function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null {
const normalized = foldRunPreviewText(value, startIndex, endIndex)
if (!normalized.text) {
return null
}
return normalized.truncated
? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...`
: normalized.text
}
function foldRunPreviewText(
value: string,
startIndex: number,
endIndex: number
): { text: string; truncated: boolean } {
let text = ''
let pendingSpace = false
let index = Math.max(0, startIndex)
const end = Math.min(value.length, endIndex)
while (index < end && text.length <= RUN_PREVIEW_LIMIT) {
if (startsWithAt(value, '```', index)) {
if (text.length > 0) {
pendingSpace = true
}
index = skipFencedBlock(value, index, end)
continue
}
const code = value.charCodeAt(index)
if (isPreviewSeparator(code)) {
if (text.length > 0) {
pendingSpace = true
}
index += 1
continue
}
if (pendingSpace) {
text += ' '
pendingSpace = false
if (text.length > RUN_PREVIEW_LIMIT) {
break
}
}
text += value[index]
index += 1
}
return { text, truncated: text.length > RUN_PREVIEW_LIMIT }
}
function isPreviewSeparator(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 35 ||
code === 40 ||
code === 41 ||
code === 42 ||
code === 62 ||
code === 91 ||
code === 93 ||
code === 95 ||
code === 96
)
}
export function parseHermesOutput(content: string): {
status: 'completed' | 'failed' | 'unknown'
outputPreview: string | null
outputContent: string
error: string | null
} {
const errorHeading = findMarkdownHeading(content, '## Error')
const responseHeading = findMarkdownHeading(content, '## Response')
const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null
const failed = hasFailedCronHeading(content) || errorHeading !== null
const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null
const previewRange = responseHeading
? { start: responseHeading.bodyStart, end: content.length }
: (errorRange ?? { start: 0, end: content.length })
return {
status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown',
outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end),
outputContent: content,
error
}
}
function hasFailedCronHeading(content: string): boolean {
let lineStart = 0
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
if (
startsWithAt(content, '#', lineStart) &&
lineContains(content, lineStart, lineEnd, 'Cron Job:') &&
lineContains(content, lineStart, lineEnd, '(FAILED)')
) {
return true
}
lineStart = nextLineStart(content, lineEnd)
}
return false
}
function findMarkdownHeading(
content: string,
heading: '## Error' | '## Response'
): { bodyStart: number } | null {
let lineStart = 0
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
if (
startsWithAt(content, heading, lineStart) &&
isHeadingBoundary(content.charCodeAt(lineStart + heading.length))
) {
return { bodyStart: nextLineStart(content, lineEnd) }
}
lineStart = nextLineStart(content, lineEnd)
}
return null
}
function errorContentRange(content: string, bodyStart: number): ContentRange {
const start = skipPreviewWhitespace(content, bodyStart, content.length)
if (!startsWithAt(content, '```', start)) {
return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length }
}
const fencedStart = nextLineStart(content, lineEndIndex(content, start))
const fencedEnd = findClosingFence(content, fencedStart) ?? content.length
return { start: fencedStart, end: fencedEnd }
}
function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number {
const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart))
const closeStart = findClosingFence(content, bodyStart)
if (closeStart === null || closeStart > endIndex) {
return endIndex
}
return nextLineStart(content, lineEndIndex(content, closeStart))
}
function findClosingFence(content: string, fromIndex: number): number | null {
let lineStart = fromIndex
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
const textStart = skipPreviewWhitespace(content, lineStart, lineEnd)
if (startsWithAt(content, '```', textStart)) {
return textStart
}
lineStart = nextLineStart(content, lineEnd)
}
return null
}
function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null {
let lineStart = fromIndex
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
if (startsWithAt(content, '## ', lineStart)) {
return lineStart
}
lineStart = nextLineStart(content, lineEnd)
}
return null
}
function lineEndIndex(value: string, startIndex: number): number {
const newline = value.indexOf('\n', startIndex)
return newline === -1 ? value.length : newline
}
function nextLineStart(value: string, lineEnd: number): number {
return lineEnd < value.length ? lineEnd + 1 : value.length
}
function lineContains(
value: string,
startIndex: number,
endIndex: number,
needle: string
): boolean {
const index = value.indexOf(needle, startIndex)
return index !== -1 && index < endIndex
}
function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number {
let index = startIndex
while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) {
index += 1
}
return index
}
function isPreviewWhitespace(code: number): boolean {
return code === 32 || (code >= 9 && code <= 13)
}
function isHeadingBoundary(code: number): boolean {
return Number.isNaN(code) || isPreviewWhitespace(code)
}
function startsWithAt(value: string, search: string, startIndex: number): boolean {
if (startIndex + search.length > value.length) {
return false
}
for (let offset = 0; offset < search.length; offset += 1) {
if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) {
return false
}
}
return true
}
@@ -1,352 +0,0 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
let hermesHome: string | null = null
let extraTempDirs: string[] = []
const previousHermesHome = process.env.HERMES_HOME
const fakeDbRows = vi.hoisted(() => ({
sessions: [] as Record<string, unknown>[],
messages: [] as Record<string, unknown>[]
}))
const fakePrepareSqls = vi.hoisted(() => [] as string[])
const fakeDatabase = vi.hoisted(() =>
vi.fn(function FakeDatabase() {
return {
prepare: vi.fn((sql: string) => {
fakePrepareSqls.push(sql)
return {
get: vi.fn((param: string) =>
sql.includes('FROM sessions')
? fakeDbRows.sessions.find((session) => session.id === param)
: undefined
),
all: vi.fn((param: string) =>
sql.includes('FROM sessions')
? fakeDbRows.sessions
: fakeDbRows.messages.filter((message) => message.session_id === param)
)
}
}),
close: vi.fn()
}
})
)
vi.mock('../sqlite/sync-database', () => ({
default: fakeDatabase
}))
async function loadReader() {
vi.resetModules()
return import('./hermes-cron-output')
}
async function createHermesHome(): Promise<string> {
hermesHome = await mkdtemp(join(tmpdir(), 'orca-hermes-output-'))
process.env.HERMES_HOME = hermesHome
return hermesHome
}
beforeEach(() => {
fakeDbRows.sessions = []
fakeDbRows.messages = []
fakePrepareSqls.length = 0
fakeDatabase.mockClear()
})
afterEach(async () => {
if (previousHermesHome === undefined) {
delete process.env.HERMES_HOME
} else {
process.env.HERMES_HOME = previousHermesHome
}
if (hermesHome) {
await rm(hermesHome, { recursive: true, force: true })
hermesHome = null
}
await Promise.all(extraTempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
extraTempDirs = []
vi.resetModules()
})
describe('readHermesCronOutputRunsPage', () => {
it('attaches the state.db transcript to the matching Hermes output file run', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
const scriptLogPath = join(home, 'Automations', 'x-automation', 'logs', 'x-monitor.log')
await mkdir(outputDir, { recursive: true })
await mkdir(dirname(scriptLogPath), { recursive: true })
await writeFile(scriptLogPath, 'raw script log line\nsecond raw script log line\n', 'utf-8')
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
`# Cron Job: Monitor automation
**Job ID:** job-1
## Response
Success — ./run-x-monitor.sh completed with exit code 0.
Latest log path: ${scriptLogPath}
Run summary: monitor automation completed successfully.
`,
'utf-8'
)
await writeFile(join(home, 'state.db'), '', 'utf-8')
fakeDbRows.sessions = [
{
id: 'cron_job-1_20260515_090000',
title: 'Monitor automation',
started_at: Date.UTC(2026, 4, 15, 9, 0, 0) / 1000,
ended_at: Date.UTC(2026, 4, 15, 9, 1, 58) / 1000,
model: 'gpt-5',
message_count: 2,
input_tokens: 100,
output_tokens: 50
}
]
fakeDbRows.messages = [
{
session_id: 'cron_job-1_20260515_090000',
role: 'tool',
content: 'full command output line',
tool_name: 'terminal',
timestamp: Date.UTC(2026, 4, 15, 9, 1, 0) / 1000
}
]
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
expect(page.total).toBe(1)
expect(page.runs[0]).toMatchObject({
id: 'job-1:2026-05-15_09-02-00.md',
status: 'completed',
output_path: join(outputDir, '2026-05-15_09-02-00.md')
})
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'monitor automation completed successfully.'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'## Latest log file'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'raw script log line'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'## Full session log'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'full command output line'
)
})
it('builds large response previews without broad regex captures', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
[
'# Cron Job: Monitor automation',
'',
'## Response',
'',
'```',
'hidden-token\n'.repeat(500),
'```',
'',
'Visible response text '.repeat(500),
''
].join('\n'),
'utf-8'
)
const { readHermesCronOutputRunsPage } = await loadReader()
const execSpy = vi.spyOn(RegExp.prototype, 'exec')
const replaceSpy = vi.spyOn(String.prototype, 'replace')
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
const usedBroadCapture = execSpy.mock.contexts.some(
(pattern) => pattern instanceof RegExp && pattern.source.includes('[\\s\\S]')
)
const usedWhitespaceReplace = replaceSpy.mock.calls.some(
([pattern]) => pattern instanceof RegExp && pattern.source === '\\s+'
)
expect(usedBroadCapture).toBe(false)
expect(usedWhitespaceReplace).toBe(false)
expect((page.runs[0] as { output_preview?: string }).output_preview).toContain(
'Visible response text'
)
expect((page.runs[0] as { output_preview?: string }).output_preview).not.toContain(
'hidden-token'
)
})
it('does not hydrate referenced logs outside Hermes home', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
const outsideDir = await mkdtemp(join(tmpdir(), 'orca-hermes-outside-'))
extraTempDirs.push(outsideDir)
const outsideLogPath = join(outsideDir, 'secret.log')
await mkdir(outputDir, { recursive: true })
await writeFile(outsideLogPath, 'do not expose this\n', 'utf-8')
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
`# Cron Job: Monitor automation
## Response
Latest log path: ${outsideLogPath}
Run summary: monitor automation completed successfully.
`,
'utf-8'
)
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
expect((page.runs[0] as { output_content?: string }).output_content).not.toContain(
'## Latest log file'
)
expect((page.runs[0] as { output_content?: string }).output_content).not.toContain(
'do not expose this'
)
})
it('hydrates referenced logs in valid dot-dot-prefixed Hermes subdirectories', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
const scriptLogPath = join(home, '..logs', 'x-monitor.log')
await mkdir(outputDir, { recursive: true })
await mkdir(dirname(scriptLogPath), { recursive: true })
await writeFile(scriptLogPath, 'dot-dot-prefixed log line\n', 'utf-8')
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
`# Cron Job: Monitor automation
## Response
Latest log path: ${scriptLogPath}
Run summary: monitor automation completed successfully.
`,
'utf-8'
)
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'## Latest log file'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'dot-dot-prefixed log line'
)
})
it('uses a count-only path when page size is zero', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
'this content should not be read for count-only listing',
'utf-8'
)
await writeFile(join(home, 'state.db'), '', 'utf-8')
fakeDbRows.sessions = [
{
id: 'cron_job-1_20260515_090000',
started_at: Date.UTC(2026, 4, 15, 9, 0, 0) / 1000
}
]
fakeDbRows.messages = [
{
session_id: 'cron_job-1_20260515_090000',
role: 'tool',
content: 'full command output line'
}
]
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })
expect(page).toEqual({ total: 1, runs: [] })
expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false)
})
it('skips date sorting for counts while keeping paginated runs newest first', async () => {
const home = await createHermesHome()
await writeFile(join(home, 'state.db'), '')
fakeDbRows.sessions = [
{ id: 'cron_job-1_older', started_at: 1000 },
{ id: 'cron_job-1_newer', started_at: 2000 }
]
const { readHermesCronOutputRunsPage } = await loadReader()
const parse = vi.spyOn(Date, 'parse')
try {
await expect(
readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })
).resolves.toEqual({
total: 2,
runs: []
})
expect(parse).not.toHaveBeenCalled()
} finally {
parse.mockRestore()
}
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 1 })
expect(page.total).toBe(2)
expect(page.runs).toMatchObject([{ id: 'cron_job-1_newer' }])
})
it('caches count-only reads until the cache is cleared', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(join(outputDir, '2026-05-15_09-02-00.md'), 'first run', 'utf-8')
const { clearHermesCronOutputRunCountCache, readHermesCronOutputRunsPage } = await loadReader()
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 1,
runs: []
})
await writeFile(join(outputDir, '2026-05-15_09-03-00.md'), 'second run', 'utf-8')
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 1,
runs: []
})
clearHermesCronOutputRunCountCache('job-1')
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 2,
runs: []
})
})
it('evicts oldest count cache entries when many job ids are observed', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-0')
await mkdir(outputDir, { recursive: true })
await writeFile(join(outputDir, '2026-05-15_09-02-00.md'), 'first run', 'utf-8')
const { readHermesCronOutputRunsPage } = await loadReader()
await expect(readHermesCronOutputRunsPage('job-0', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 1,
runs: []
})
for (let i = 1; i <= 200; i += 1) {
await readHermesCronOutputRunsPage(`job-${i}`, { page: 1, pageSize: 0 })
}
await writeFile(join(outputDir, '2026-05-15_09-03-00.md'), 'second run', 'utf-8')
await expect(readHermesCronOutputRunsPage('job-0', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 2,
runs: []
})
})
})
-290
View File
@@ -1,290 +0,0 @@
import { existsSync } from 'node:fs'
import { readdir, readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import Database from '../sqlite/sync-database'
import {
HERMES_OUTPUT_FILE_PATTERN,
escapeSqlLike,
parseHermesOutput,
runAtFromHermesOutputFile,
runAtFromUnixSeconds,
runKeyFromHermesOutputFile
} from './hermes-cron-output-markdown'
import {
appendReferencedLogFile,
formatSessionMessages,
mergeHermesOutputAndSessionRunRefs,
mergeHermesOutputAndSessionRuns
} from './hermes-cron-run-content'
import {
clearHermesCronOutputRunCountCache as clearHermesRunCountCache,
readCachedHermesRunCount
} from './hermes-cron-run-count-cache'
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
const HERMES_OUTPUT_DIR = join(HERMES_HOME, 'cron', 'output')
const HERMES_STATE_DB = join(HERMES_HOME, 'state.db')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
export type HermesCronOutputRunsPage = {
total: number
runs: unknown[]
}
export type HermesOutputRunRef = {
kind: 'output'
id: string
job_id: string
run_at: string | null
run_key: string | null
output_path: string
}
export type HermesSessionRunRef = {
kind: 'session'
id: string
job_id: string
run_at: string | null
run_key: string | null
}
export type HermesMergedRunRef = {
id: string
job_id: string
run_at: string | null
run_key: string | null
output: HermesOutputRunRef | null
session: HermesSessionRunRef | null
}
export async function readHermesCronOutputRuns(jobId: string): Promise<unknown[]> {
return (await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: Number.MAX_SAFE_INTEGER }))
.runs
}
async function readHermesCronOutputRunRefs(jobId: string): Promise<HermesMergedRunRef[]> {
const outputRuns = await readHermesOutputFileRunRefs(jobId)
return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId))
}
// Why: opening the Automations page calls readHermesCronOutputRunsPage with
// pageSize:0 once per local Hermes job to populate badge counts. Without a
// cache this performs N readdir + N sqlite open/query on every list call,
// which scales linearly with job and run-history size on the main process.
export function clearHermesCronOutputRunCountCache(jobId?: string): void {
clearHermesRunCountCache(jobId)
}
async function readHermesCronOutputRunCount(jobId: string): Promise<number> {
return readCachedHermesRunCount(jobId, async (targetJobId) => {
const refs = await readHermesCronOutputRunRefs(targetJobId)
return refs.length
})
}
async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise<unknown> {
const outputRun = ref.output ? await readHermesOutputFileRun(ref.output) : null
const sessionRun = ref.session ? readHermesSessionDbRunById(jobId, ref.session.id) : null
return (
mergeHermesOutputAndSessionRuns(
outputRun ? [outputRun] : [],
sessionRun ? [sessionRun] : []
)[0] ??
outputRun ??
sessionRun ??
ref
)
}
export async function readHermesCronOutputRunsPage(
jobId: string,
{
page,
pageSize
}: {
page: number
pageSize: number
}
): Promise<HermesCronOutputRunsPage> {
if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) {
return { total: 0, runs: [] }
}
const safePage = Math.max(1, Math.floor(page))
const safePageSize = Math.max(0, Math.floor(pageSize))
if (safePageSize === 0) {
// Why: manager listing only needs a badge count; hydrating markdown logs
// and full session transcripts can make opening Automations very slow.
return { total: await readHermesCronOutputRunCount(jobId), runs: [] }
}
const runRefs = await readHermesCronOutputRunRefs(jobId)
runRefs.sort((a, b) => {
const aTime = getRawRunTime(a)
const bTime = getRawRunTime(b)
if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
return bTime - aTime
}
return getRawRunId(b).localeCompare(getRawRunId(a))
})
const start = (safePage - 1) * safePageSize
const pageRefs = runRefs.slice(start, start + safePageSize)
return {
total: runRefs.length,
runs: await Promise.all(pageRefs.map((ref) => hydrateHermesRunRef(jobId, ref)))
}
}
function getRawRunId(run: unknown): string {
if (typeof run === 'object' && run !== null && 'id' in run) {
return String((run as { id: unknown }).id)
}
return ''
}
function getRawRunTime(run: unknown): number {
if (typeof run !== 'object' || run === null || !('run_at' in run)) {
return Number.NaN
}
const runAt = (run as { run_at: unknown }).run_at
return typeof runAt === 'string' ? Date.parse(runAt) : Number.NaN
}
async function readHermesOutputFileRunRefs(jobId: string): Promise<HermesOutputRunRef[]> {
const outputDir = join(HERMES_OUTPUT_DIR, jobId)
if (!existsSync(outputDir)) {
return []
}
const entries = await readdir(outputDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name))
.map((entry) => ({
kind: 'output' as const,
id: `${jobId}:${entry.name}`,
job_id: jobId,
run_at: runAtFromHermesOutputFile(entry.name),
run_key: runKeyFromHermesOutputFile(entry.name),
output_path: join(outputDir, entry.name)
}))
}
async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise<unknown> {
try {
const content = await readFile(ref.output_path, 'utf-8')
const parsed = parseHermesOutput(content)
const outputContent = await appendReferencedLogFile(parsed.outputContent)
return {
id: ref.id,
job_id: ref.job_id,
run_at: ref.run_at,
run_key: ref.run_key,
status: parsed.status,
output_preview: parsed.outputPreview,
output_content: outputContent,
error: parsed.error,
output_path: ref.output_path
}
} catch (error) {
return {
id: ref.id,
job_id: ref.job_id,
run_at: ref.run_at,
run_key: ref.run_key,
status: 'unknown',
output_preview: null,
output_content: null,
error: error instanceof Error ? error.message : String(error),
output_path: ref.output_path
}
}
}
function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] {
if (!existsSync(HERMES_STATE_DB)) {
return []
}
try {
const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true })
try {
const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%`
const rows = db
.prepare(
`SELECT id, started_at
FROM sessions
WHERE id LIKE ? ESCAPE '\\'
ORDER BY started_at DESC`
)
.all(pattern) as Record<string, unknown>[]
return rows.map((row) => {
const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}`
return {
kind: 'session',
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null
}
})
} finally {
db.close()
}
} catch {
return []
}
}
function readHermesSessionDbRunById(jobId: string, runId: string): unknown {
if (!existsSync(HERMES_STATE_DB)) {
return null
}
try {
const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true })
try {
const row = db
.prepare(
`SELECT id, title, started_at, ended_at, end_reason, model, message_count,
input_tokens, output_tokens, estimated_cost_usd
FROM sessions
WHERE id = ?`
)
.get(runId) as Record<string, unknown> | undefined
if (!row) {
return null
}
const messages = db
.prepare(
`SELECT role, content, tool_name, reasoning, reasoning_content
FROM messages
WHERE session_id = ?
ORDER BY timestamp, id`
)
.all(runId) as Record<string, unknown>[]
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null
const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null
const messageCount = typeof row.message_count === 'number' ? row.message_count : null
const tokenCount =
(typeof row.input_tokens === 'number' ? row.input_tokens : 0) +
(typeof row.output_tokens === 'number' ? row.output_tokens : 0)
const summaryParts = [
title,
model ? `Model: ${model}` : null,
messageCount !== null ? `${messageCount} messages` : null,
tokenCount > 0 ? `${tokenCount} tokens` : null
].filter(Boolean)
return {
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null,
status: typeof row.ended_at === 'number' ? 'completed' : 'unknown',
output_preview: summaryParts.join(' · ') || null,
output_content: formatSessionMessages(messages),
error: null,
output_path: HERMES_STATE_DB
}
} finally {
db.close()
}
} catch {
return null
}
}
@@ -1,234 +0,0 @@
import { HermesSessionRunIndex } from '../../shared/hermes-session-run-index'
import { open, readFile, realpath, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import {
FULL_SESSION_LOG_HEADING,
LATEST_LOG_PATH_PATTERN,
MAX_REFERENCED_LOG_BYTES,
MAX_SESSION_OUTPUT_GAP_MS,
REFERENCED_LOG_HEADING,
asString,
isRecord,
sortableTimeFromRunKey
} from './hermes-cron-output-markdown'
import type {
HermesMergedRunRef,
HermesOutputRunRef,
HermesSessionRunRef
} from './hermes-cron-output'
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
function extractLatestLogPath(content: string): string | null {
const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim()
if (!rawPath) {
return null
}
return rawPath.replace(/^`|`$/g, '').trim()
}
async function readReferencedLogFile(content: string): Promise<{
path: string
content: string
truncated: boolean
} | null> {
const logPath = extractLatestLogPath(content)
if (!logPath || !isAbsolute(logPath)) {
return null
}
try {
const homeRealPath = await realpath(HERMES_HOME)
const logRealPath = await realpath(logPath)
const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath))
// Why: the output body can contain agent-authored text, so only hydrate
// referenced files that resolve inside Hermes' own data directory.
if (
relativeToHermesHome === '..' ||
relativeToHermesHome.startsWith(`..${sep}`) ||
isAbsolute(relativeToHermesHome)
) {
return null
}
const logStat = await stat(logPath)
if (!logStat.isFile()) {
return null
}
if (logStat.size <= MAX_REFERENCED_LOG_BYTES) {
return {
path: logPath,
content: await readFile(logPath, 'utf-8'),
truncated: false
}
}
const file = await open(logPath, 'r')
try {
const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES)
await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES)
return {
path: logPath,
content: buffer.toString('utf-8'),
truncated: true
}
} finally {
await file.close()
}
} catch {
return null
}
}
export async function appendReferencedLogFile(content: string): Promise<string> {
if (content.includes(REFERENCED_LOG_HEADING)) {
return content
}
const logFile = await readReferencedLogFile(content)
if (!logFile) {
return content
}
const note = logFile.truncated
? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.`
: null
return [
content,
'---',
REFERENCED_LOG_HEADING,
'',
`Path: ${logFile.path}`,
note,
'```text',
logFile.content.trimEnd(),
'```'
]
.filter((part) => part !== null)
.join('\n\n')
}
export function formatSessionMessages(messages: Record<string, unknown>[]): string | null {
if (messages.length === 0) {
return null
}
return messages
.map((message) => {
const role = typeof message.role === 'string' ? message.role : 'message'
const content = typeof message.content === 'string' ? message.content.trim() : ''
const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : ''
const reasoning =
typeof message.reasoning_content === 'string'
? message.reasoning_content.trim()
: typeof message.reasoning === 'string'
? message.reasoning.trim()
: ''
const parts = [
`## ${role}${toolName ? ` / ${toolName}` : ''}`,
reasoning ? `### Reasoning\n\n${reasoning}` : null,
content || '(empty)'
].filter(Boolean)
return parts.join('\n\n')
})
.join('\n\n---\n\n')
}
function getRunKey(run: unknown): string | null {
return isRecord(run) ? asString(run.run_key) : null
}
function getRunOutputContent(run: unknown): string | null {
return isRecord(run) ? asString(run.output_content) : null
}
function mergeOutputAndSessionContent(
outputContent: string | null,
sessionContent: string | null
): string | null {
if (!sessionContent) {
return outputContent
}
if (!outputContent) {
return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
}
if (outputContent.includes(FULL_SESSION_LOG_HEADING)) {
return outputContent
}
return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
}
export function mergeHermesOutputAndSessionRuns(
outputRuns: unknown[],
sessionRuns: unknown[]
): unknown[] {
const sessionIndex = new HermesSessionRunIndex(
outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [],
sortableTimeFromRunKey,
MAX_SESSION_OUTPUT_GAP_MS
)
const usedSessionRunIndexes = sessionIndex.used
const mergedOutputRuns = outputRuns.map((outputRun) => {
if (!isRecord(outputRun)) {
return outputRun
}
const sessionRunIndex = sessionIndex.find(getRunKey(outputRun))
if (sessionRunIndex === null) {
return outputRun
}
const sessionRun = sessionRuns[sessionRunIndex]
if (!isRecord(sessionRun)) {
return outputRun
}
sessionIndex.use(sessionRunIndex)
// Hermes writes the markdown output at completion, while state.db keeps
// the actual turn-by-turn transcript under the cron session start time.
return {
...outputRun,
output_preview: asString(outputRun.output_preview) ?? asString(sessionRun.output_preview),
output_content: mergeOutputAndSessionContent(
getRunOutputContent(outputRun),
getRunOutputContent(sessionRun)
)
}
})
return [
...mergedOutputRuns,
...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index))
]
}
export function mergeHermesOutputAndSessionRunRefs(
outputRefs: HermesOutputRunRef[],
sessionRefs: HermesSessionRunRef[]
): HermesMergedRunRef[] {
const sessionIndex = new HermesSessionRunIndex(
outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [],
sortableTimeFromRunKey,
MAX_SESSION_OUTPUT_GAP_MS
)
const usedSessionRunIndexes = sessionIndex.used
const mergedOutputRefs = outputRefs.map((outputRef) => {
const sessionRunIndex = sessionIndex.find(getRunKey(outputRef))
const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex]
if (sessionRunIndex !== null) {
sessionIndex.use(sessionRunIndex)
}
return {
id: outputRef.id,
job_id: outputRef.job_id,
run_at: outputRef.run_at,
run_key: outputRef.run_key,
output: outputRef,
session: sessionRef
}
})
return [
...mergedOutputRefs,
...sessionRefs
.filter((_, index) => !usedSessionRunIndexes.has(index))
.map((sessionRef) => ({
id: sessionRef.id,
job_id: sessionRef.job_id,
run_at: sessionRef.run_at,
run_key: sessionRef.run_key,
output: null,
session: sessionRef
}))
]
}
@@ -1,64 +0,0 @@
const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000
const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200
type HermesRunCountCacheEntry = {
promise: Promise<number>
expiresAt: number
}
const hermesRunCountCache = new Map<string, HermesRunCountCacheEntry>()
export function clearHermesCronOutputRunCountCache(jobId?: string): void {
if (jobId) {
hermesRunCountCache.delete(jobId)
return
}
hermesRunCountCache.clear()
}
function pruneHermesRunCountCache(now: number): void {
for (const [jobId, entry] of hermesRunCountCache) {
if (entry.expiresAt <= now) {
hermesRunCountCache.delete(jobId)
}
}
while (hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) {
const oldestJobId = hermesRunCountCache.keys().next().value
if (oldestJobId === undefined) {
return
}
hermesRunCountCache.delete(oldestJobId)
}
}
export async function readCachedHermesRunCount(
jobId: string,
loadRunCount: (jobId: string) => Promise<number>
): Promise<number> {
const now = Date.now()
const cached = hermesRunCountCache.get(jobId)
if (cached && cached.expiresAt > now) {
return cached.promise
}
if (cached) {
hermesRunCountCache.delete(jobId)
}
// Why: external Hermes jobs can be created/removed outside Orca; without a
// size bound and expired sweep, a long session can pin stale job ids forever.
pruneHermesRunCountCache(now)
const entry: HermesRunCountCacheEntry = {
promise: loadRunCount(jobId),
expiresAt: Number.POSITIVE_INFINITY
}
hermesRunCountCache.set(jobId, entry)
try {
const count = await entry.promise
entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS
return count
} catch (error) {
if (hermesRunCountCache.get(jobId) === entry) {
hermesRunCountCache.delete(jobId)
}
throw error
}
}
+70 -21
View File
@@ -8,6 +8,7 @@ import type { AutomationService } from '../automations/service'
import type { Store } from '../persistence'
import type * as Fs from 'node:fs'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
import { ExternalAutomationManagerCache } from '../automations/external-automation-manager-cache'
type IpcHandler = (event: unknown, ...args: unknown[]) => unknown
@@ -23,11 +24,13 @@ vi.mock('electron', () => ({
// This computer's `which` lookup is the one process a scoped probe starts, so it counts probes.
const probeCommand = vi.hoisted(() => vi.fn())
const fileExists = vi.hoisted(() => vi.fn(() => false))
const readRegistrations = vi.fn(() => state.targets)
vi.mock('@orca/process-host', () => ({ runProcess: probeCommand }))
// Why: keep the local job read off the developer's real ~/.hermes and ~/.openclaw.
// Why: keep the local job read off the developer's real ~/.openclaw and ~/.openclaw.
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof Fs>()),
existsSync: () => false
existsSync: fileExists
}))
const PROBE_FOUND = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false }
@@ -76,14 +79,18 @@ beforeEach(async () => {
ipcHandlers.clear()
probeCommand.mockReset()
probeCommand.mockResolvedValue(PROBE_FOUND)
fileExists.mockClear()
readRegistrations.mockClear()
state.targets = [sshTarget()]
state.service = { externalProbePriority: null }
const { registerAutomationHandlers } = await import('./automations')
const store = {
getSshTargets: readRegistrations,
assertAutomationOwnerFence: () => undefined
}
registerAutomationHandlers(
{
getSshTargets: () => state.targets,
assertAutomationOwnerFence: () => undefined
} as unknown as Store,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: handler registration reads only these store methods.
store as unknown as Store,
state.service as unknown as AutomationService
)
})
@@ -93,10 +100,51 @@ afterEach(() => {
})
describe('scoped external-manager IPC', () => {
it.each(['hermes', 'unknown', 'toString'])(
'rejects %s before cache, filesystem, process, or host work',
async (provider) => {
const cacheRead = vi.spyOn(ExternalAutomationManagerCache.prototype, 'resolve')
const cacheWrite = vi.spyOn(ExternalAutomationManagerCache.prototype, 'invalidateOwner')
try {
for (const owner of [desktopSelf, desktopSsh()]) {
await expect(
invoke('automations:listExternalManagerForOwner', { owner, provider })
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed)
await expect(
invoke('automations:runExternalActionForOwner', {
owner,
provider,
jobId: 'job-1',
action: 'delete'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed)
}
expect(cacheRead).not.toHaveBeenCalled()
expect(cacheWrite).not.toHaveBeenCalled()
expect(fileExists).not.toHaveBeenCalled()
expect(probeCommand).not.toHaveBeenCalled()
expect(readRegistrations).not.toHaveBeenCalled()
} finally {
cacheRead.mockRestore()
cacheWrite.mockRestore()
}
}
)
it('does not register external create, edit, or history handlers', () => {
for (const channel of [
'automations:createExternalForOwner',
'automations:updateExternalForOwner',
'automations:listExternalRunsForOwner'
]) {
expect(ipcHandlers.has(channel)).toBe(false)
}
})
it('derives the target from the captured owner rather than the payload', async () => {
const entry = await invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh(),
provider: 'hermes',
provider: 'openclaw',
target: { type: 'local' }
})
@@ -107,7 +155,7 @@ describe('scoped external-manager IPC', () => {
it('refuses a request with no captured owner without probing', async () => {
await expect(
invoke('automations:listExternalManagerForOwner', { provider: 'hermes' })
invoke('automations:listExternalManagerForOwner', { provider: 'openclaw' })
).rejects.toThrow(/automation owner is required/i)
await expect(invoke('automations:runExternalActionForOwner', {})).rejects.toThrow(
/automation owner is required/i
@@ -122,7 +170,7 @@ describe('scoped external-manager IPC', () => {
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 2 },
selector: { kind: 'self' }
},
provider: 'hermes'
provider: 'openclaw'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported)
expect(probeCommand).not.toHaveBeenCalled()
@@ -131,7 +179,10 @@ describe('scoped external-manager IPC', () => {
it('refuses a runtime-owned SSH target as hidden rather than as a probe failure', async () => {
state.targets = [sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'env-1' } })]
await expect(
invoke('automations:listExternalManagerForOwner', { owner: desktopSsh(), provider: 'hermes' })
invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh(),
provider: 'openclaw'
})
).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden)
expect(probeCommand).not.toHaveBeenCalled()
})
@@ -141,14 +192,14 @@ describe('scoped external-manager IPC', () => {
await expect(
invoke('automations:listExternalManagerForOwner', {
owner: desktopSsh('t1', 3),
provider: 'hermes'
provider: 'openclaw'
})
).rejects.toThrow(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
expect(probeCommand).not.toHaveBeenCalled()
})
it('keeps one cache across requests, so a repeat read costs no second probe', async () => {
const request = { owner: desktopSelf, provider: 'hermes' }
const request = { owner: desktopSelf, provider: 'openclaw' }
await invoke('automations:listExternalManagerForOwner', request)
await invoke('automations:listExternalManagerForOwner', request)
expect(probeCommand).toHaveBeenCalledTimes(1)
@@ -158,18 +209,16 @@ describe('scoped external-manager IPC', () => {
})
it('invalidates that host after a mutation through the scoped path', async () => {
const request = { owner: desktopSelf, provider: 'hermes' }
const request = { owner: desktopSelf, provider: 'openclaw' }
await invoke('automations:listExternalManagerForOwner', request)
await invoke('automations:createExternalForOwner', {
await invoke('automations:runExternalActionForOwner', {
...request,
name: 'Nightly',
prompt: 'sweep',
schedule: '0 9 * * *',
workdir: null
jobId: 'job-1',
action: 'run'
})
await invoke('automations:listExternalManagerForOwner', request)
// create + the re-probe after invalidation, on top of the first list.
// action + the re-probe after invalidation, on top of the first list.
expect(probeCommand).toHaveBeenCalledTimes(3)
})
})
@@ -185,7 +234,7 @@ describe('probe scope retention', () => {
)
const pending = invoke('automations:listExternalManagerForOwner', {
owner: desktopSelf,
provider: 'hermes'
provider: 'openclaw'
})
const settled = pending.catch((error: unknown) => error)
await Promise.resolve()
@@ -214,7 +263,7 @@ describe('Orca automation traffic priority', () => {
const probe = invoke('automations:listExternalManagerForOwner', {
owner: desktopSelf,
provider: 'hermes'
provider: 'openclaw'
})
await Promise.resolve()
// Why: the probe is queued, not started — this is the only thing keeping
+1 -20
View File
@@ -4,7 +4,6 @@ import type { AutomationService } from '../automations/service'
import type {
AutomationDispatchResult,
AutomationPrecheckResult,
ExternalAutomationRunsPage,
AutomationRun
} from '../../shared/automations-types'
import { createScopedExternalAutomations } from '../automations/external-manager'
@@ -17,10 +16,7 @@ import { ownerKey } from '../../shared/automation-owner-key'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import type {
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
ScopedExternalManagerListRequest
} from '../../shared/external-automation-scope'
/**
@@ -85,21 +81,6 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ
): Promise<ExternalAutomationManagerCacheEntry> =>
scopedExternal.listManager(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:listExternalRunsForOwner',
(_event, request: ScopedExternalManagerRunsRequest): Promise<ExternalAutomationRunsPage> =>
scopedExternal.listRuns(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:createExternalForOwner',
(_event, request: ScopedExternalManagerCreateRequest): Promise<void> =>
scopedExternal.create(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:updateExternalForOwner',
(_event, request: ScopedExternalManagerUpdateRequest): Promise<void> =>
scopedExternal.update(requireCapturedOwner(request))
)
ipcMain.handle(
'automations:runExternalActionForOwner',
(_event, request: ScopedExternalManagerActionRequest): Promise<void> =>
+2 -11
View File
@@ -3,16 +3,12 @@ import type {
AutomationDispatchResult,
AutomationPrecheckResult,
AutomationRun,
ExternalAutomationManager,
ExternalAutomationRunsPage
ExternalAutomationManager
} from '../../shared/automations-types'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import type {
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
ScopedExternalManagerListRequest
} from '../../shared/external-automation-scope'
import type { AutomationsChangedPayload } from '../../shared/runtime-client-events'
@@ -32,11 +28,6 @@ export type AutomationsApi = {
listExternalManagerForOwner: (
request: ScopedExternalManagerListRequest
) => Promise<ExternalAutomationManagerResult>
listExternalRunsForOwner: (
request: ScopedExternalManagerRunsRequest
) => Promise<ExternalAutomationRunsPage>
createExternalForOwner: (request: ScopedExternalManagerCreateRequest) => Promise<void>
updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest) => Promise<void>
runExternalActionForOwner: (request: ScopedExternalManagerActionRequest) => Promise<void>
retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }) => Promise<void>
runPrecheck: (args: {
+1 -13
View File
@@ -3,17 +3,13 @@ import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types'
import type {
AutomationDispatchRequest,
AutomationDispatchResult,
ExternalAutomationRunsPage,
AutomationRun,
AutomationPrecheckResult
} from '../../shared/automations-types'
import type { AutomationOwnerRef } from '../../shared/automation-owner-ref'
import type {
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
ScopedExternalManagerListRequest
} from '../../shared/external-automation-scope'
import type { AutomationsChangedPayload } from '../../shared/runtime-client-events'
@@ -22,14 +18,6 @@ export const automationsApi = {
request: ScopedExternalManagerListRequest
): Promise<ExternalAutomationManagerResult> =>
ipcRenderer.invoke('automations:listExternalManagerForOwner', request),
listExternalRunsForOwner: (
request: ScopedExternalManagerRunsRequest
): Promise<ExternalAutomationRunsPage> =>
ipcRenderer.invoke('automations:listExternalRunsForOwner', request),
createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise<void> =>
ipcRenderer.invoke('automations:createExternalForOwner', request),
updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise<void> =>
ipcRenderer.invoke('automations:updateExternalForOwner', request),
runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise<void> =>
ipcRenderer.invoke('automations:runExternalActionForOwner', request),
retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise<void> =>
@@ -59,15 +59,11 @@ export type AutomationDraft = {
scheduleWarning: string | null
}
export type AutomationCreateTarget = 'orca' | 'hermes'
type AutomationEditorDialogProps = {
open: boolean
isEditing: boolean
isEditingExternal: boolean
isSaving: boolean
canSave: boolean
createTarget: AutomationCreateTarget
repos: readonly Repo[]
projectHostSetups: readonly ProjectHostSetup[]
automationYamlHooksByRepoKey: Record<string, OrcaHooks | null>
@@ -87,7 +83,6 @@ type AutomationEditorDialogProps = {
onProjectChange: (projectId: string) => void
getRepoHostLabel?: (repo: Repo) => string | null | undefined
allowAddProject?: boolean
onCreateTargetChange: (target: AutomationCreateTarget) => void
onOpenChange: (open: boolean) => void
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
onSetupDecisionTouched: () => void
@@ -98,10 +93,8 @@ type AutomationEditorDialogProps = {
export function AutomationEditorDialog({
open,
isEditing,
isEditingExternal,
isSaving,
canSave,
createTarget,
repos,
projectHostSetups,
automationYamlHooksByRepoKey,
@@ -118,7 +111,6 @@ export function AutomationEditorDialog({
onProjectChange,
getRepoHostLabel,
allowAddProject,
onCreateTargetChange,
onOpenChange,
onDraftChange,
onSetupDecisionTouched,
@@ -127,9 +119,7 @@ export function AutomationEditorDialog({
}: AutomationEditorDialogProps): React.JSX.Element {
const [templateOpen, setTemplateOpen] = React.useState(false)
const dialogContentRef = React.useRef<HTMLDivElement>(null)
const isHermesTarget = createTarget === 'hermes'
const isCreateMode = !isEditing && !isEditingExternal
const isHermesCreate = isCreateMode && isHermesTarget
const isCreateMode = !isEditing
const destination = isCreateMode ? createDestination : editDestination
const visibleAgents = React.useMemo(() => {
const enabledIds = new Set(
@@ -172,15 +162,9 @@ export function AutomationEditorDialog({
>
<AutomationEditorDialogHeader
isEditing={isEditing}
isEditingExternal={isEditingExternal}
isHermesCreate={isHermesCreate}
isCreateMode={isCreateMode}
createTarget={createTarget}
templateOpen={templateOpen}
templates={getAutomationTemplates()}
segmentedGroupClassName={AUTOMATION_EDITOR_SEGMENTED_GROUP_CLASS}
segmentedItemClassName={AUTOMATION_EDITOR_SEGMENTED_ITEM_CLASS}
onCreateTargetChange={onCreateTargetChange}
onTemplateOpenChange={setTemplateOpen}
onApplyTemplate={(template) => {
onApplyTemplate(template)
@@ -196,8 +180,6 @@ export function AutomationEditorDialog({
/>
<AutomationEditorSettingsSidebar
destination={destination}
isHermesTarget={isHermesTarget}
isHermesCreate={isHermesCreate}
repos={repos}
projectHostSetups={projectHostSetups}
automationYamlHooksByRepoKey={automationYamlHooksByRepoKey}
@@ -227,8 +209,6 @@ export function AutomationEditorDialog({
<AutomationEditorDialogFooter
isEditing={isEditing}
isEditingExternal={isEditingExternal}
isHermesCreate={isHermesCreate}
isSaving={isSaving}
canSave={canSave}
hasProjects={repos.length > 0}
@@ -4,8 +4,6 @@ import { translate } from '@/i18n/i18n'
type AutomationEditorDialogFooterProps = {
isEditing: boolean
isEditingExternal: boolean
isHermesCreate: boolean
isSaving: boolean
canSave: boolean
hasProjects: boolean
@@ -15,20 +13,17 @@ type AutomationEditorDialogFooterProps = {
export function AutomationEditorDialogFooter({
isEditing,
isEditingExternal,
isHermesCreate,
isSaving,
canSave,
hasProjects,
onOpenChange,
onSave
}: AutomationEditorDialogFooterProps): React.JSX.Element {
const saveLabel =
isEditing || isEditingExternal
? translate('auto.components.automations.AutomationEditorDialog.777548c2d6', 'Save Changes')
: isSaving || isHermesCreate
? translate('auto.components.automations.AutomationEditorDialog.a9d9dccf77', 'Save')
: translate('auto.components.automations.AutomationEditorDialog.e46c1aa9ad', 'Create')
const saveLabel = isEditing
? translate('auto.components.automations.AutomationEditorDialog.777548c2d6', 'Save Changes')
: isSaving
? translate('auto.components.automations.AutomationEditorDialog.a9d9dccf77', 'Save')
: translate('auto.components.automations.AutomationEditorDialog.e46c1aa9ad', 'Create')
return (
<div className="flex items-center justify-between gap-3 border-t border-border/50 px-5 py-3">
@@ -2,22 +2,14 @@ import { Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { AutomationCreateTarget } from './AutomationEditorDialog'
import type { AutomationTemplate } from './automation-templates'
import { translate } from '@/i18n/i18n'
type AutomationEditorDialogHeaderProps = {
isEditing: boolean
isEditingExternal: boolean
isHermesCreate: boolean
isCreateMode: boolean
createTarget: AutomationCreateTarget
templateOpen: boolean
templates: AutomationTemplate[]
segmentedGroupClassName: string
segmentedItemClassName: string
onCreateTargetChange: (target: AutomationCreateTarget) => void
onTemplateOpenChange: (open: boolean) => void
onApplyTemplate: (template: AutomationTemplate) => void
}
@@ -44,29 +36,13 @@ function AutomationTemplateCard({
)
}
function getEditorTitle(args: {
isEditing: boolean
isEditingExternal: boolean
isHermesCreate: boolean
}): string {
function getEditorTitle(args: { isEditing: boolean }): string {
if (args.isEditing) {
return translate(
'auto.components.automations.AutomationEditorDialogHeader.17086b48ee',
'Edit automation'
)
}
if (args.isEditingExternal) {
return translate(
'auto.components.automations.AutomationEditorDialogHeader.03142e7721',
'Edit Hermes automation'
)
}
if (args.isHermesCreate) {
return translate(
'auto.components.automations.AutomationEditorDialogHeader.0a75e5e2fa',
'Create Hermes automation'
)
}
return translate(
'auto.components.automations.AutomationEditorDialogHeader.4133d33862',
'Create automation'
@@ -75,48 +51,19 @@ function getEditorTitle(args: {
export function AutomationEditorDialogHeader({
isEditing,
isEditingExternal,
isHermesCreate,
isCreateMode,
createTarget,
templateOpen,
templates,
segmentedGroupClassName,
segmentedItemClassName,
onCreateTargetChange,
onTemplateOpenChange,
onApplyTemplate
}: AutomationEditorDialogHeaderProps): React.JSX.Element {
const title = getEditorTitle({ isEditing, isEditingExternal, isHermesCreate })
const title = getEditorTitle({ isEditing })
return (
<DialogHeader className="flex-row items-center justify-between gap-3 border-b border-border/50 px-5 py-2.5 pr-12 text-left">
<DialogTitle className="min-w-0 truncate text-sm font-medium">{title}</DialogTitle>
{isCreateMode ? (
<div className="flex shrink-0 items-center gap-2">
<ToggleGroup
type="single"
spacing={1}
value={createTarget}
onValueChange={(value) =>
value && onCreateTargetChange(value as AutomationCreateTarget)
}
size="sm"
className={segmentedGroupClassName}
>
<ToggleGroupItem value="orca" className={segmentedItemClassName}>
{translate(
'auto.components.automations.AutomationEditorDialogHeader.6f309eef8d',
'Orca'
)}
</ToggleGroupItem>
<ToggleGroupItem value="hermes" className={segmentedItemClassName}>
{translate(
'auto.components.automations.AutomationEditorDialogHeader.7e35393632',
'Hermes'
)}
</ToggleGroupItem>
</ToggleGroup>
<Popover open={templateOpen} onOpenChange={onTemplateOpenChange}>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm">
@@ -41,8 +41,6 @@ const DRAFT: AutomationDraft = {
function renderSidebar(onDraftChange: (updater: (d: AutomationDraft) => AutomationDraft) => void) {
return render(
<AutomationEditorSettingsSidebar
isHermesTarget={false}
isHermesCreate={false}
repos={[]}
projectHostSetups={[]}
automationYamlHooksByRepoKey={{}}
@@ -1,10 +1,6 @@
import AgentCombobox from '@/components/agent/AgentCombobox'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
isValidAutomationCronSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedule-parsing'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
@@ -27,8 +23,6 @@ import type { AutomationDraft } from './AutomationEditorDialog'
type AutomationEditorSettingsSidebarProps = {
/** The host picker: where a create lands, or the host an edit can move the record to. */
destination?: AutomationCreateDestinationControl
isHermesTarget: boolean
isHermesCreate: boolean
repos: readonly Repo[]
projectHostSetups: readonly ProjectHostSetup[]
automationYamlHooksByRepoKey: Record<string, OrcaHooks | null>
@@ -50,8 +44,6 @@ type AutomationEditorSettingsSidebarProps = {
export function AutomationEditorSettingsSidebar({
destination,
isHermesTarget,
isHermesCreate,
repos,
projectHostSetups,
automationYamlHooksByRepoKey,
@@ -73,46 +65,26 @@ export function AutomationEditorSettingsSidebar({
return (
<aside className="flex w-[320px] shrink-0 flex-col overflow-auto border-l border-border/50 bg-muted/20 px-5 py-5 scrollbar-sleek">
<div className="flex flex-col">
{/* Why: Hermes keeps project/workspace/schedule only. Collapse the
Orca-only knobs so switching the create target does not jump. */}
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,margin] duration-200 ease-out',
isHermesTarget ? 'mb-0 grid-rows-[0fr]' : 'mb-4 grid-rows-[1fr]'
)}
aria-hidden={isHermesTarget}
inert={isHermesTarget}
>
<div className="min-h-0">
<div
className={cn(
'flex flex-col gap-5 transition-[opacity,transform] duration-150 ease-out',
isHermesTarget
? '-translate-y-1 opacity-0 delay-0'
: 'translate-y-0 opacity-100 delay-200'
)}
>
<Field
labelClassName={AUTOMATION_EDITOR_SECTION_LABEL_CLASS}
label={translate(
'auto.components.automations.AutomationEditorDialog.57b722cbba',
'Agent'
)}
>
<AgentCombobox
agents={visibleAgents}
value={draft.agentId}
onValueChange={(agentId) => onDraftChange((current) => ({ ...current, agentId }))}
defaultAgent={settings?.defaultTuiAgent ?? null}
triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`}
allowNarrowTrigger
/>
</Field>
<AutomationExtraAgentArgsField draft={draft} onDraftChange={onDraftChange} />
</div>
</div>
<div className="mb-4 flex flex-col gap-5">
<Field
labelClassName={AUTOMATION_EDITOR_SECTION_LABEL_CLASS}
label={translate(
'auto.components.automations.AutomationEditorDialog.57b722cbba',
'Agent'
)}
>
<AgentCombobox
agents={visibleAgents}
value={draft.agentId}
onValueChange={(agentId) => onDraftChange((current) => ({ ...current, agentId }))}
defaultAgent={settings?.defaultTuiAgent ?? null}
triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`}
allowNarrowTrigger
/>
</Field>
<AutomationExtraAgentArgsField draft={draft} onDraftChange={onDraftChange} />
</div>
{destination && !isHermesTarget ? (
{destination ? (
<div className="mb-4">
<AutomationDestinationField
control={destination}
@@ -144,7 +116,6 @@ export function AutomationEditorSettingsSidebar({
<div className="mb-4">
<AutomationWorkspaceField
draft={draft}
isHermesTarget={isHermesTarget}
worktrees={worktrees}
repoMap={repoMap}
pickerTriggerClassName={pickerTriggerClassName}
@@ -153,22 +124,13 @@ export function AutomationEditorSettingsSidebar({
onDraftChange={onDraftChange}
/>
</div>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,margin] duration-200 ease-out',
isHermesTarget ? 'mb-0 grid-rows-[0fr]' : 'mb-4 grid-rows-[1fr]'
)}
aria-hidden={isHermesTarget}
inert={isHermesTarget}
>
<div className="min-h-0">
<AutomationSessionField
draft={draft}
toggleGroupClassName={segmentedGroupClassName}
toggleItemClassName={segmentedItemClassName}
onDraftChange={onDraftChange}
/>
</div>
<div className="mb-4">
<AutomationSessionField
draft={draft}
toggleGroupClassName={segmentedGroupClassName}
toggleItemClassName={segmentedItemClassName}
onDraftChange={onDraftChange}
/>
</div>
<Field
className="mb-4"
@@ -180,50 +142,29 @@ export function AutomationEditorSettingsSidebar({
>
<AutomationSchedulePicker
draft={draft}
validateAdvancedSchedule={
isHermesTarget ? isValidAutomationCronSchedule : isValidAutomationSchedule
}
validateAdvancedSchedule={isValidAutomationSchedule}
onDraftChange={onDraftChange}
/>
</Field>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,margin] duration-200 ease-out',
isHermesTarget ? 'mb-0 grid-rows-[0fr]' : 'mb-4 grid-rows-[1fr]'
)}
aria-hidden={isHermesTarget}
inert={isHermesTarget}
>
<div className="min-h-0">
<AutomationMissedRunGraceField
<div className="mb-4">
<AutomationMissedRunGraceField
disabled={false}
draft={draft}
pickerTriggerClassName={pickerTriggerClassName}
onDraftChange={onDraftChange}
/>
</div>
<div className="mb-4">
<div className="flex flex-col gap-5">
<AutomationPrecheckFields
disabled={false}
draft={draft}
disabled={isHermesTarget}
pickerTriggerClassName={pickerTriggerClassName}
onDraftChange={onDraftChange}
/>
</div>
</div>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,margin] duration-200 ease-out',
isHermesCreate ? 'mb-0 grid-rows-[0fr]' : 'mb-4 grid-rows-[1fr]'
)}
aria-hidden={isHermesCreate}
inert={isHermesCreate}
>
<div className="min-h-0">
<div className="flex flex-col gap-5">
<AutomationPrecheckFields
draft={draft}
disabled={isHermesCreate}
pickerTriggerClassName={pickerTriggerClassName}
onDraftChange={onDraftChange}
/>
</div>
</div>
</div>
<AutomationSetupDecisionField
createTarget={isHermesTarget ? 'hermes' : 'orca'}
draft={draft}
repos={repos}
projectHostSetups={projectHostSetups}
@@ -1,5 +1,5 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import { MoreHorizontal, Pause, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
@@ -56,11 +56,6 @@ export type AutomationListExternalRowProps = {
action: ExternalAutomationAction,
scope: ExternalAutomationScope
) => void
onEdit: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
}
export function AutomationListExternalRow({
@@ -70,8 +65,7 @@ export function AutomationListExternalRow({
sshConnectionStates,
externalActionKey,
onSelect,
onRequestAction,
onEdit
onRequestAction
}: AutomationListExternalRowProps): React.JSX.Element {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
@@ -171,15 +165,6 @@ export function AutomationListExternalRow({
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</DropdownMenuItem>
{entry.manager.provider === 'hermes' ? (
<DropdownMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() =>
@@ -220,15 +205,6 @@ export function AutomationListExternalRow({
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</ContextMenuItem>
{entry.manager.provider === 'hermes' ? (
<ContextMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</ContextMenuItem>
) : null}
<ContextMenuItem
disabled={actionDisabled}
onSelect={() =>
@@ -55,7 +55,7 @@ function makeExternalJob(overrides: Partial<ExternalAutomationJob> = {}): Extern
return {
id: 'job-1',
managerId: 'manager-1',
provider: 'hermes',
provider: 'openclaw',
name: 'Nightly',
schedule: '0 0 * * *',
rawSchedule: null,
@@ -77,8 +77,8 @@ function makeExternalJob(overrides: Partial<ExternalAutomationJob> = {}): Extern
function makeExternalManager(): ExternalAutomationManager {
return {
id: 'manager-1',
provider: 'hermes',
label: 'Local Hermes',
provider: 'openclaw',
label: 'Local OpenClaw',
targetLabel: 'Local',
target: { type: 'local' },
status: 'available',
@@ -161,7 +161,7 @@ function renderExternalRows(handlers: {
const job = makeExternalJob()
const scope: ExternalAutomationScope = {
owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } },
provider: 'hermes'
provider: 'openclaw'
}
return render(
<AutomationListExternalRows
@@ -172,7 +172,6 @@ function renderExternalRows(handlers: {
externalActionKey={null}
onSelect={handlers.onSelect}
onRequestAction={handlers.onRequestAction ?? vi.fn()}
onEdit={vi.fn()}
/>
)
}
@@ -4,13 +4,12 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { getVisibleAutomationSetupDecision } from './automation-setup-decision'
import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog'
import type { AutomationDraft } from './AutomationEditorDialog'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
type AutomationSetupDecisionFieldProps = {
createTarget: AutomationCreateTarget
draft: AutomationDraft
repos: readonly Repo[]
projectHostSetups: readonly ProjectHostSetup[]
@@ -20,7 +19,6 @@ type AutomationSetupDecisionFieldProps = {
}
export function AutomationSetupDecisionField({
createTarget,
draft,
repos,
projectHostSetups,
@@ -30,7 +28,6 @@ export function AutomationSetupDecisionField({
}: AutomationSetupDecisionFieldProps): React.JSX.Element | null {
const [advancedOpen, setAdvancedOpen] = React.useState(false)
const defaultDecision = getVisibleAutomationSetupDecision({
createTarget,
workspaceMode: draft.workspaceMode,
repoId: draft.projectId,
repos,
@@ -12,7 +12,6 @@ import type { AutomationDraft } from './AutomationEditorDialog'
type AutomationWorkspaceFieldProps = {
draft: AutomationDraft
isHermesTarget: boolean
worktrees: Worktree[]
repoMap: Map<string, Repo>
pickerTriggerClassName: string
@@ -23,7 +22,6 @@ type AutomationWorkspaceFieldProps = {
export function AutomationWorkspaceField({
draft,
isHermesTarget,
worktrees,
repoMap,
pickerTriggerClassName,
@@ -60,69 +58,52 @@ export function AutomationWorkspaceField({
</span>
}
>
{isHermesTarget ? (
<WorkspaceCombobox
worktrees={worktrees}
value={draft.workspaceId}
triggerClassName={pickerTriggerClassName}
onValueChange={(workspaceId) => onDraftChange((current) => ({ ...current, workspaceId }))}
/>
) : (
<div className="grid gap-2">
<ToggleGroup
type="single"
spacing={1}
value={draft.workspaceMode}
onValueChange={(workspaceMode) =>
workspaceMode &&
onDraftChange((current) => ({
...current,
workspaceMode: workspaceMode as AutomationWorkspaceMode,
reuseSession: workspaceMode === 'existing' ? current.reuseSession : false
}))
<div className="grid gap-2">
<ToggleGroup
type="single"
spacing={1}
value={draft.workspaceMode}
onValueChange={(workspaceMode) =>
workspaceMode &&
onDraftChange((current) => ({
...current,
workspaceMode: workspaceMode as AutomationWorkspaceMode,
reuseSession: workspaceMode === 'existing' ? current.reuseSession : false
}))
}
size="sm"
className={segmentedGroupClassName}
>
<ToggleGroupItem value="existing" className={segmentedItemClassName}>
{translate('auto.components.automations.AutomationEditorDialog.a2e688226d', 'Worktree')}
</ToggleGroupItem>
<ToggleGroupItem value="new_per_run" className={segmentedItemClassName}>
{translate('auto.components.automations.AutomationEditorDialog.6ff66f9012', 'New run')}
</ToggleGroupItem>
</ToggleGroup>
{draft.workspaceMode === 'existing' ? (
<WorkspaceCombobox
worktrees={worktrees}
value={draft.workspaceId}
triggerClassName={`min-w-0 ${pickerTriggerClassName}`}
onValueChange={(workspaceId) =>
onDraftChange((current) => ({ ...current, workspaceId }))
}
size="sm"
className={segmentedGroupClassName}
>
<ToggleGroupItem value="existing" className={segmentedItemClassName}>
{translate(
'auto.components.automations.AutomationEditorDialog.a2e688226d',
'Worktree'
)}
</ToggleGroupItem>
<ToggleGroupItem value="new_per_run" className={segmentedItemClassName}>
{translate(
'auto.components.automations.AutomationEditorDialog.6ff66f9012',
'New run'
)}
</ToggleGroupItem>
</ToggleGroup>
{draft.workspaceMode === 'existing' ? (
<WorkspaceCombobox
worktrees={worktrees}
value={draft.workspaceId}
triggerClassName={`min-w-0 ${pickerTriggerClassName}`}
onValueChange={(workspaceId) =>
onDraftChange((current) => ({ ...current, workspaceId }))
}
/>
) : (
<CreateFromPicker
// Why: branch search state belongs to the selected project,
// so repo switches should reset it before the next paint.
key={draft.projectId}
repoId={draft.projectId}
repoMap={repoMap}
worktrees={worktrees}
value={draft.baseBranch}
triggerClassName={`min-w-0 ${pickerTriggerClassName}`}
onValueChange={(baseBranch) =>
onDraftChange((current) => ({ ...current, baseBranch }))
}
/>
)}
</div>
)}
/>
) : (
<CreateFromPicker
// Why: branch search state belongs to the selected project,
// so repo switches should reset it before the next paint.
key={draft.projectId}
repoId={draft.projectId}
repoMap={repoMap}
worktrees={worktrees}
value={draft.baseBranch}
triggerClassName={`min-w-0 ${pickerTriggerClassName}`}
onValueChange={(baseBranch) => onDraftChange((current) => ({ ...current, baseBranch }))}
/>
)}
</div>
</Field>
)
}
@@ -27,7 +27,6 @@ async function renderRunsTab(historyUnavailable: boolean): Promise<HTMLButtonEle
<AutomationsDetailPane
selected={makeAutomation()}
selectedExternal={null}
selectedExternalRunPage={null}
selectedRuns={[]}
selectedRunsNotice={
historyUnavailable
@@ -44,12 +43,8 @@ async function renderRunsTab(historyUnavailable: boolean): Promise<HTMLButtonEle
hostLabelById={new Map()}
selectedRunNowAvailability={null}
worktreeMap={new Map()}
fetchExternalAutomationRuns={vi.fn()}
onActivePaneTabChange={vi.fn()}
onClearExternalRunPage={vi.fn()}
requestExternalAction={vi.fn()}
openExternalRunPage={vi.fn()}
openEditExternalDialog={vi.fn()}
runNow={vi.fn()}
openEditDialog={vi.fn()}
toggleAutomation={vi.fn()}
@@ -46,7 +46,6 @@ function renderDetailPane(options: {
<AutomationsDetailPane
selected={selected}
selectedExternal={null}
selectedExternalRunPage={null}
selectedRuns={[]}
selectedRunsNotice={null}
activePaneTab={activePaneTab}
@@ -59,12 +58,8 @@ function renderDetailPane(options: {
hostLabelById={new Map()}
selectedRunNowAvailability={null}
worktreeMap={new Map()}
fetchExternalAutomationRuns={async () => []}
onActivePaneTabChange={onActivePaneTabChange}
onClearExternalRunPage={() => undefined}
requestExternalAction={() => undefined}
openExternalRunPage={() => undefined}
openEditExternalDialog={() => undefined}
runNow={() => undefined}
openEditDialog={() => undefined}
toggleAutomation={() => undefined}
@@ -169,7 +164,6 @@ describe('AutomationsDetailPane tab keyboard navigation', () => {
<AutomationsDetailPane
selected={selected}
selectedExternal={null}
selectedExternalRunPage={null}
selectedRuns={[]}
selectedRunsNotice={null}
activePaneTab="overview"
@@ -182,12 +176,8 @@ describe('AutomationsDetailPane tab keyboard navigation', () => {
hostLabelById={new Map()}
selectedRunNowAvailability={null}
worktreeMap={new Map()}
fetchExternalAutomationRuns={async () => []}
onActivePaneTabChange={() => undefined}
onClearExternalRunPage={() => undefined}
requestExternalAction={() => undefined}
openExternalRunPage={() => undefined}
openEditExternalDialog={() => undefined}
runNow={() => undefined}
openEditDialog={() => undefined}
toggleAutomation={() => undefined}
@@ -7,30 +7,19 @@ import type {
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun,
AutomationRun
} from '../../../../shared/automations-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { AutomationDetail } from './AutomationDetail'
import { HermesCronOutputView } from './HermesCronOutputView'
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
import { AutomationRunHistory } from './AutomationRunHistory'
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import {
formatExternalDate,
getExternalProviderLabel,
getExternalRunContent,
getExternalRunStatusLabel,
getExternalRunStatusVariant
} from './external-automation-display'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationTargetAvailability } from './automation-target-availability'
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
import type { AutomationPaneTab } from './automation-page-state'
import {
getAutomationDetailNextTab,
shouldHandleAutomationDetailEscapeKey,
@@ -41,7 +30,6 @@ import { translate } from '@/i18n/i18n'
type AutomationsDetailPaneProps = {
selected: Automation | null
selectedExternal: ExternalAutomationListEntry | null
selectedExternalRunPage: SelectedExternalRunPage | null
selectedRuns: AutomationRun[]
/** Set when the selected automation's history read failed; its runs are unknown. */
selectedRunsNotice: AutomationActionNotice | null
@@ -56,25 +44,13 @@ type AutomationsDetailPaneProps = {
hostLabelById: ReadonlyMap<string, string>
selectedRunNowAvailability: AutomationTargetAvailability | null
worktreeMap: ReadonlyMap<string, Worktree>
fetchExternalAutomationRuns: FetchExternalAutomationRuns
onActivePaneTabChange: (tab: AutomationPaneTab) => void
onClearExternalRunPage: () => void
requestExternalAction: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction,
scope: ExternalAutomationScope
) => void
openExternalRunPage: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
run: ExternalAutomationRun
) => void
openEditExternalDialog: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
runNow: (automation: Automation) => void
openEditDialog: (automation: Automation) => void
toggleAutomation: (automation: Automation) => void
@@ -87,7 +63,6 @@ type AutomationsDetailPaneProps = {
export function AutomationsDetailPane({
selected,
selectedExternal,
selectedExternalRunPage,
selectedRuns,
selectedRunsNotice,
activePaneTab,
@@ -100,12 +75,8 @@ export function AutomationsDetailPane({
hostLabelById,
selectedRunNowAvailability,
worktreeMap,
fetchExternalAutomationRuns,
onActivePaneTabChange,
onClearExternalRunPage,
requestExternalAction,
openExternalRunPage,
openEditExternalDialog,
runNow,
openEditDialog,
toggleAutomation,
@@ -118,10 +89,6 @@ export function AutomationsDetailPane({
const handleKeyDown = (event: KeyboardEvent): void => {
if (shouldHandleAutomationDetailEscapeKey(event)) {
event.preventDefault()
if (selectedExternalRunPage) {
onClearExternalRunPage()
return
}
onBackToList()
return
}
@@ -145,15 +112,7 @@ export function AutomationsDetailPane({
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
activePaneTab,
onActivePaneTabChange,
onBackToList,
onClearExternalRunPage,
selected,
selectedExternal,
selectedExternalRunPage
])
}, [activePaneTab, onActivePaneTabChange, onBackToList, selected, selectedExternal])
return (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -168,42 +127,22 @@ export function AutomationsDetailPane({
)}
</Button>
</div>
{selectedExternalRunPage ? (
<AutomationRunPageFrame
title={selectedExternalRunPage.job.name}
breadcrumbs={[
formatExternalDate(selectedExternalRunPage.run.runAt, relativeNow),
getExternalProviderLabel(selectedExternalRunPage.manager),
selectedExternalRunPage.manager.targetLabel
]}
detail={selectedExternalRunPage.run.outputPath}
statusLabel={getExternalRunStatusLabel(selectedExternalRunPage.run)}
statusVariant={getExternalRunStatusVariant(selectedExternalRunPage.run)}
onBack={onClearExternalRunPage}
>
<HermesCronOutputView content={getExternalRunContent(selectedExternalRunPage.run)} />
</AutomationRunPageFrame>
) : (
<ExternalAutomationManagers
managers={[
{
// The synthesized single-job manager keeps the entry's scope, so
// every action it dispatches names the host the row came from.
scope: selectedExternal.scope,
manager: {
...selectedExternal.manager,
jobs: [selectedExternal.job]
}
<ExternalAutomationManagers
managers={[
{
// The synthesized single-job manager keeps the entry's scope, so
// every action it dispatches names the host the row came from.
scope: selectedExternal.scope,
manager: {
...selectedExternal.manager,
jobs: [selectedExternal.job]
}
]}
now={relativeNow}
runningActionKey={externalActionKey}
onAction={requestExternalAction}
onFetchRuns={fetchExternalAutomationRuns}
onOpenRun={openExternalRunPage}
onEdit={openEditExternalDialog}
/>
)}
}
]}
now={relativeNow}
runningActionKey={externalActionKey}
onAction={requestExternalAction}
/>
</div>
) : (
<Tabs
@@ -134,7 +134,6 @@ function renderPanel(
toggleAutomation={() => undefined}
requestDeleteAutomation={() => undefined}
requestExternalAction={() => undefined}
openEditExternalDialog={() => undefined}
openCreateDialog={() => undefined}
onOpenDetail={options.onOpenDetail ?? (() => undefined)}
onRefresh={() => undefined}
@@ -102,11 +102,6 @@ export type AutomationsListPanelProps = {
action: ExternalAutomationAction,
scope: ExternalAutomationScope
) => void
openEditExternalDialog: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
openCreateDialog: (template?: AutomationTemplate) => void
canCreateAutomation: boolean
onOpenDetail: () => void
@@ -155,7 +150,6 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
toggleAutomation,
requestDeleteAutomation,
requestExternalAction,
openEditExternalDialog,
openCreateDialog,
canCreateAutomation,
onOpenDetail,
@@ -359,7 +353,6 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
onOpenDetail()
}}
onRequestAction={requestExternalAction}
onEdit={openEditExternalDialog}
/>
)
)}
@@ -57,10 +57,10 @@ function lastRetainedOwners(): readonly AutomationOwnerRef[] {
return (calls.at(-1)?.[0] as { owners: readonly AutomationOwnerRef[] } | undefined)?.owners ?? []
}
function hermesOnly(): void {
function openclawOnly(): void {
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
provider === 'openclaw'
? { manager: makeExternalManager(), error: null, updatedAt: 1 }
: { manager: null, error: null, updatedAt: 1 }
)
@@ -70,20 +70,20 @@ describe('AutomationsPage external manager probes', () => {
it('probes no SSH host when the selection is the local desktop', async () => {
withSshHosts('staging', 'web-01', 'web-02')
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
hermesOnly()
openclawOnly()
await renderPage()
await settleHostQueries()
const sshProbes = probedOwners().filter((owner) => owner.selector.kind === 'ssh')
expect(sshProbes).toEqual([])
expect(probedOwners()).toEqual([DESKTOP_SELF_OWNER, DESKTOP_SELF_OWNER])
expect(probedOwners()).toEqual([DESKTOP_SELF_OWNER])
})
it('retains only the selected host, so probes elsewhere are cancelled', async () => {
withSshHosts('staging', 'web-01', 'web-02')
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
hermesOnly()
openclawOnly()
await renderPage()
await settleHostQueries()
@@ -94,7 +94,7 @@ describe('AutomationsPage external manager probes', () => {
it('cancels the previous host as soon as the filter moves off it', async () => {
withSshHosts('staging')
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
hermesOnly()
openclawOnly()
const { rerender } = await renderPage()
await settleHostQueries()
@@ -113,7 +113,7 @@ describe('AutomationsPage external manager probes', () => {
it('lists no external rows for a host whose managers it says are not listed', async () => {
runtimeHost([], [])
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
hermesOnly()
openclawOnly()
await renderPage()
await settleHostQueries()
@@ -124,7 +124,7 @@ describe('AutomationsPage external manager probes', () => {
it('drops the previous host rows when the selection moves, not when the new probe lands', async () => {
withSshHosts('staging')
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
hermesOnly()
openclawOnly()
const { rerender } = await renderPage()
await settleHostQueries()
@@ -145,8 +145,8 @@ describe('AutomationsPage external manager probes', () => {
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
? { manager: null, error: 'hermes probe failed: channel closed', updatedAt: 0 }
provider === 'openclaw'
? { manager: null, error: 'openclaw probe failed: channel closed', updatedAt: 0 }
: { manager: null, error: null, updatedAt: 0 }
)
@@ -161,7 +161,7 @@ describe('AutomationsPage external manager probes', () => {
it('says nothing about unchecked hosts when every probe answered', async () => {
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
hermesOnly()
openclawOnly()
await renderPage()
await settleHostQueries()
@@ -34,6 +34,7 @@ import { listedRow, listedExternalEntries } from './automations-page-listed-item
import {
makeAutomation,
makeExternalManager,
makeRun,
REPO_ID,
WORKSPACE_ID
} from './automations-page-fixtures'
@@ -122,7 +123,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.list.mockResolvedValue([])
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
provider === 'openclaw'
? { manager: makeExternalManager(), error: null, updatedAt: 1 }
: { manager: null, error: null, updatedAt: 1 }
)
@@ -131,47 +132,18 @@ describe('AutomationsPage list rendering', () => {
expect(api.automations.listExternalManagerForOwner).toHaveBeenCalledWith({
owner: DESKTOP_SELF_OWNER,
provider: 'hermes'
provider: 'openclaw'
})
expect(rows(container, 'external-row')).toEqual(['Hermes job'])
expect(api.automations.listExternalManagerForOwner).toHaveBeenCalledTimes(1)
expect(rows(container, 'external-row')).toEqual(['OpenClaw job'])
})
it('keeps the edited external automation selected after its save', async () => {
it('routes each authority’s action to its own host when both report openclaw:local', async () => {
api.automations.list.mockResolvedValue([])
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
? { manager: makeExternalManager(), error: null, updatedAt: 1 }
: { manager: null, error: null, updatedAt: 1 }
)
api.automations.updateExternalForOwner.mockResolvedValue(undefined)
await renderPage()
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to edit')
}
await act(async () => {
mocks.listPanel?.openEditExternalDialog(entry.manager, entry.job, entry.scope)
})
await act(async () => {
mocks.editorDialog?.onSave()
})
// The row key and the post-save selection are built by two different call
// sites; if they ever stop agreeing, selection silently falls through to the
// first row and the user's edit appears to open someone else's automation.
expect(api.automations.updateExternalForOwner).toHaveBeenCalled()
expect(mocks.listPanel?.selectedExternal?.key).toBe(entry.key)
})
it('routes each authority’s action to its own host when both report hermes:local', async () => {
api.automations.list.mockResolvedValue([])
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
? { manager: makeExternalManager({ id: 'hermes:local' }), error: null, updatedAt: 1 }
provider === 'openclaw'
? { manager: makeExternalManager({ id: 'openclaw:local' }), error: null, updatedAt: 1 }
: { manager: null, error: null, updatedAt: 1 }
)
api.automations.runExternalActionForOwner.mockResolvedValue(undefined)
@@ -182,7 +154,7 @@ describe('AutomationsPage list rendering', () => {
throw new Error('no external entry to act on')
}
// The second authority a multi-host build will list: same provider, same
// `hermes:local` manager ID, different machine. The desktop fixture is the
// `openclaw:local` manager ID, different machine. The desktop fixture is the
// only host that actually answers, so a scope recovered from the manager ID
// would resolve BOTH rows to it and pause the wrong host's cron job.
const runtimeScope = {
@@ -205,51 +177,6 @@ describe('AutomationsPage list rendering', () => {
)
expect(owners).toEqual([entry.scope.owner, runtimeScope.owner])
})
it('reads runs from the host the table names, not the one holding the manager ID', async () => {
api.automations.list.mockResolvedValue([])
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
? { manager: makeExternalManager({ id: 'hermes:local' }), error: null, updatedAt: 1 }
: { manager: null, error: null, updatedAt: 1 }
)
api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 })
const { container } = await renderPage()
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to read runs for')
}
const runtimeScope = {
...entry.scope,
owner: {
authority: { kind: 'runtime' as const, environmentId: 'env-7', pairingRevision: 1 },
selector: { kind: 'self' as const }
}
}
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="external-row"]')?.click()
})
await act(async () => {
await mocks.detailPane?.fetchExternalAutomationRuns({
scope: runtimeScope,
manager: entry.manager,
job: entry.job,
page: 0,
pageSize: 8
})
})
// Run output is read, not written, but a wrong host still shows one machine's
// logs under another's automation — and the desktop is the only host listed,
// so an ID-keyed lookup would answer with it every time.
expect(api.automations.listExternalRunsForOwner).toHaveBeenCalledWith(
expect.objectContaining({ owner: runtimeScope.owner, jobId: entry.job.id })
)
})
})
describe('AutomationsPage host filter', () => {
@@ -360,14 +287,25 @@ describe('AutomationsPage mutations', () => {
})
})
it('re-reads the record before opening the edit dialog', async () => {
const automation = makeAutomation({ id: 'a-1' })
it('opens an Orca-owned Hermes record in the normal editor with its history', async () => {
const automation = makeAutomation({
id: 'a-1',
agentId: 'hermes',
workspaceMode: 'existing',
workspaceId: WORKSPACE_ID
})
const run = makeRun({ automationId: automation.id })
api.automations.list.mockResolvedValue([automation])
scopedList([automation])
api.automations.listRuns.mockResolvedValue([run])
await renderPage()
api.automations.list.mockClear()
await act(async () => {
mocks.listPanel?.openEditDialog(listedRow(automation.id))
mocks.listPanel?.selectAutomationRow(listedRow(automation.id).key)
})
await act(async () => {
mocks.detailPane?.openEditDialog(automation)
})
// Step 6: hydration re-reads the row's own scope; the ambient list is untouched.
@@ -375,6 +313,16 @@ describe('AutomationsPage mutations', () => {
expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } })
expect(mocks.editorDialog?.open).toBe(true)
expect(mocks.editorDialog?.isEditing).toBe(true)
expect(mocks.editorDialog?.draft).toMatchObject({
agentId: 'hermes',
workspaceMode: automation.workspaceMode,
workspaceId: automation.workspaceId,
savedSchedule: automation.rrule
})
expect(listedRow(automation.id).automation).toEqual(automation)
expect(api.automations.update).not.toHaveBeenCalled()
expect(api.automations.runExternalActionForOwner).not.toHaveBeenCalled()
expect(mocks.detailPane?.selectedRuns).toEqual([run])
})
it('opens the create dialog without an editing target', async () => {
@@ -407,7 +355,7 @@ describe('AutomationsPage mutations', () => {
})
})
it('creates at an explicitly stated destination rather than an inferred host', async () => {
it('creates an Orca automation with Hermes at the stated destination', async () => {
api.automations.list.mockResolvedValue([])
await renderPage()
@@ -419,6 +367,7 @@ describe('AutomationsPage mutations', () => {
...(current as Record<string, unknown>),
name: 'Sweep',
prompt: 'Do the sweep',
agentId: 'hermes',
projectId: REPO_ID,
workspaceMode: 'existing',
workspaceId: WORKSPACE_ID
@@ -431,9 +380,11 @@ describe('AutomationsPage mutations', () => {
expect(api.automations.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Sweep',
agentId: 'hermes',
destination: { selector: { kind: 'self' } }
})
)
expect(api.automations.runExternalActionForOwner).not.toHaveBeenCalled()
})
it('refreshes after a mutation so the list reflects the write', async () => {
@@ -107,7 +107,6 @@ export function AutomationsPageListPanel({
toggleAutomation={(row) => void managementActions.toggleAutomation(row)}
requestDeleteAutomation={managementActions.requestDeleteAutomation}
requestExternalAction={externalActions.requestExternalAction}
openEditExternalDialog={editorActions.openEditExternalDialog}
openCreateDialog={editorActions.openCreateDialog}
canCreateAutomation={destination.canCreateAutomation}
onOpenDetail={onOpenDetail}
@@ -49,8 +49,6 @@ export function AutomationsPageSurface({
createOpen,
editingAutomationId,
isSaving,
editingExternalTarget,
createTarget,
automationYamlHooksByRepoKey,
draft,
setCreateOpen,
@@ -66,8 +64,6 @@ export function AutomationsPageSurface({
externalActionKey,
activePaneTab,
setActivePaneTab,
selectedExternalRunPage,
setSelectedExternalRunPage,
setSelectedAutomationRunPageId,
setSelectedAutomationRuns,
setRunHistoryReloadToken,
@@ -156,12 +152,8 @@ export function AutomationsPageSurface({
isEditing={editingAutomationId !== null}
isSaving={isSaving}
canSave={presentation.canSaveDraft}
isEditingExternal={editingExternalTarget !== null}
createTarget={createTarget}
createDestination={destination.createDestination.control}
editDestination={
destinationForm.isOrcaForm ? destinationForm.editDestinationControl : undefined
}
editDestination={destinationForm.editDestinationControl}
notice={editorNotice}
onNoticeRecover={(action) => {
const host = editorNoticeHost ?? destination.editorRecoveryHost
@@ -187,15 +179,13 @@ export function AutomationsPageSurface({
onProjectChange={editorActions.handleProjectChange}
getRepoHostLabel={presentation.getAutomationRepoHostLabel}
allowAddProject={
!destinationForm.isOrcaForm ||
(editingAutomationId !== null
editingAutomationId !== null
? destinationForm.editHostResolution.status === 'ready'
? getAutomationAuthorityTarget(destinationForm.editHostResolution.authority).kind ===
'local'
: destinationForm.automationDialogTarget.kind === 'local'
: destinationForm.automationDialogTarget.kind === 'local')
: destinationForm.automationDialogTarget.kind === 'local'
}
onCreateTargetChange={draftEffects.handleCreateTargetChange}
onOpenChange={(open) => {
setCreateOpen(open)
if (!open) {
@@ -263,7 +253,6 @@ export function AutomationsPageSurface({
<AutomationsDetailPane
selected={selected}
selectedExternal={selectedExternal}
selectedExternalRunPage={selectedExternalRunPage}
selectedRuns={setup.selectedRuns}
selectedRunsNotice={setup.selectedRunsNotice}
selectedHostEntry={destination.rowRecoveryHost(selectedRow?.key ?? null)}
@@ -295,12 +284,8 @@ export function AutomationsPageSurface({
hostLabelById={presentation.hostLabelById}
selectedRunNowAvailability={presentation.selectedRunNowAvailability}
worktreeMap={selectedRunWorktreeMap}
fetchExternalAutomationRuns={externalActions.fetchExternalAutomationRuns}
onActivePaneTabChange={setActivePaneTab}
onClearExternalRunPage={() => setSelectedExternalRunPage(null)}
requestExternalAction={externalActions.requestExternalAction}
openExternalRunPage={externalActions.openExternalRunPage}
openEditExternalDialog={editorActions.openEditExternalDialog}
runNow={() => runSelectedRowAction(runActions.runNow)}
openEditDialog={() => runSelectedRowAction(editorActions.openEditDialog)}
toggleAutomation={() => runSelectedRowAction(managementActions.toggleAutomation)}
@@ -311,7 +296,6 @@ export function AutomationsPageSurface({
onBackToList={() => {
setIsDetailOpen(false)
setSelectedAutomationRunPageId(null)
setSelectedExternalRunPage(null)
setActivePaneTab('overview')
}}
/>
@@ -41,7 +41,7 @@ function makeJob(overrides: Partial<ExternalAutomationJob> = {}): ExternalAutoma
return {
id: 'job-1',
managerId: 'manager-1',
provider: 'hermes',
provider: 'openclaw',
name: 'Nightly backup',
schedule: '0 0 * * *',
rawSchedule: null,
@@ -63,11 +63,11 @@ function makeJob(overrides: Partial<ExternalAutomationJob> = {}): ExternalAutoma
function makeManager(
overrides: Partial<ExternalAutomationManager> = {}
): ExternalAutomationManager {
const provider: ExternalAutomationProvider = overrides.provider ?? 'hermes'
const provider: ExternalAutomationProvider = overrides.provider ?? 'openclaw'
return {
id: 'manager-1',
provider,
label: 'Local Hermes',
label: 'Local OpenClaw',
targetLabel: 'Local',
target: { type: 'local' },
status: 'available',
@@ -88,7 +88,7 @@ function makeScope(environmentId: string | null = null): ExternalAutomationScope
: { kind: 'runtime', environmentId, pairingRevision: 1 },
selector: { kind: 'self' }
},
provider: 'hermes'
provider: 'openclaw'
}
}
@@ -109,28 +109,16 @@ type OnActionMock = ReturnType<
) => void
>
>
type OnEditMock = ReturnType<
typeof vi.fn<
(
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
>
>
type RenderOptions = {
runningActionKey?: string | null
onAction?: OnActionMock
onEdit?: OnEditMock
}
function renderManagers(
managers: ScopedExternalAutomationManager[],
options: RenderOptions = {}
): { onAction: OnActionMock; onEdit: OnEditMock } {
): { onAction: OnActionMock } {
const onAction = options.onAction ?? vi.fn()
const onEdit = options.onEdit ?? vi.fn()
act(() => {
root.render(
<ExternalAutomationManagers
@@ -138,11 +126,10 @@ function renderManagers(
now={0}
runningActionKey={options.runningActionKey ?? null}
onAction={onAction}
onEdit={onEdit}
/>
)
})
return { onAction, onEdit }
return { onAction }
}
function getSwitch(index = 0): HTMLButtonElement {
@@ -228,12 +215,12 @@ describe('ExternalAutomationManagers toggle', () => {
})
it('spins only the host whose action is in flight when two authorities share a manager ID', () => {
// Both hosts report `hermes:local` with the same job ID, because that ID
// Both hosts report `openclaw:local` with the same job ID, because that ID
// names a provider and a target kind but no authority. The page keys the
// in-flight action by scope, so the component must too — matching on
// manager and job alone would spin the wrong host's row, and a user would
// watch a machine they never touched appear to run their automation.
const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1', enabled: true })] }
const local = { id: 'openclaw:local', jobs: [makeJob({ id: 'job-1', enabled: true })] }
const desktop = scoped(local, makeScope())
const runtime = scoped(local, makeScope('env-7'))
@@ -250,7 +237,7 @@ describe('ExternalAutomationManagers toggle', () => {
})
it('labels the two rows apart when two authorities share a manager and job ID', () => {
const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1' })] }
const local = { id: 'openclaw:local', jobs: [makeJob({ id: 'job-1' })] }
renderManagers([scoped(local, makeScope()), scoped(local, makeScope('env-7'))])
// A name element ID built from manager and job alone would repeat, and the
@@ -261,7 +248,7 @@ describe('ExternalAutomationManagers toggle', () => {
})
it('dispatches each row against its own authority when two share a manager ID', () => {
const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1', enabled: true })] }
const local = { id: 'openclaw:local', jobs: [makeJob({ id: 'job-1', enabled: true })] }
const desktop = scoped(local, makeScope())
const runtime = scoped(local, makeScope('env-7'))
const { onAction } = renderManagers([desktop, runtime])
@@ -276,16 +263,6 @@ describe('ExternalAutomationManagers toggle', () => {
expect(onAction.mock.calls[0]?.[3]).toEqual(runtime.scope)
})
it('keeps the Run/Edit/Delete actions and removes the pause/resume button on hermes', () => {
renderManagers([scoped({ provider: 'hermes' })])
const labels = actionButtonLabels()
expect(labels).toContain('Run external automation')
expect(labels).toContain('Edit external automation')
expect(labels).toContain('Delete external automation')
expect(labels).not.toContain('Pause external automation')
expect(labels).not.toContain('Resume external automation')
})
it('keeps Run/Delete (no Edit) for openclaw and removes the pause/resume button', () => {
renderManagers([scoped({ provider: 'openclaw' })])
const labels = actionButtonLabels()
@@ -1,5 +1,5 @@
import React from 'react'
import { Pencil, Play, RefreshCw, Trash2 } from 'lucide-react'
import { Play, RefreshCw, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
@@ -7,8 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import type {
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type {
ExternalAutomationScope,
@@ -24,10 +23,6 @@ import {
getExternalProviderLabel,
getExternalTargetKindLabel
} from './external-automation-display'
import {
ExternalAutomationRunTable,
type FetchExternalAutomationRuns
} from './ExternalAutomationRunTable'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { translate } from '@/i18n/i18n'
@@ -43,17 +38,6 @@ type ExternalAutomationManagersProps = {
action: ExternalAutomationAction,
scope: ExternalAutomationScope
) => void
onFetchRuns?: FetchExternalAutomationRuns
onOpenRun?: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
run: ExternalAutomationRun
) => void
onEdit?: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
}
function ExternalActionButton({
@@ -94,10 +78,7 @@ export function ExternalAutomationManagers({
managers,
now,
runningActionKey,
onAction,
onFetchRuns,
onOpenRun,
onEdit
onAction
}: ExternalAutomationManagersProps): React.JSX.Element {
const automationCount = managers.reduce((sum, { manager }) => sum + manager.jobs.length, 0)
@@ -234,24 +215,6 @@ export function ExternalAutomationManagers({
{formatExternalDate(job.nextRunAt, now)} ·{' '}
{getExternalProviderLabel(manager)} / {manager.targetLabel}
</div>
{manager.provider === 'hermes' ? (
<div className="mt-1 truncate text-xs text-muted-foreground">
{job.runCount}{' '}
{job.runCount === 1
? translate(
'auto.components.automations.ExternalAutomationManagers.8e9165af08',
'run'
)
: translate(
'auto.components.automations.ExternalAutomationManagers.e66091daf4',
'runs'
)}{' '}
{translate(
'auto.components.automations.ExternalAutomationManagers.844f1acb72',
'found'
)}
</div>
) : null}
{job.promptPreview || job.lastError ? (
<div className="mt-1 truncate text-xs text-muted-foreground">
{job.lastError ?? job.promptPreview}
@@ -284,21 +247,6 @@ export function ExternalAutomationManagers({
<Play className="size-3.5" />
)}
</ExternalActionButton>
{manager.provider === 'hermes' ? (
<ExternalActionButton
label={
disabledMessage ??
translate(
'auto.components.automations.ExternalAutomationManagers.1df491fd00',
'Edit external automation'
)
}
disabled={disabledMessage !== null}
onClick={() => onEdit?.(manager, job, scope)}
>
<Pencil className="size-3.5" />
</ExternalActionButton>
) : null}
<ExternalActionButton
label={
disabledMessage ??
@@ -318,18 +266,6 @@ export function ExternalAutomationManagers({
)}
</ExternalActionButton>
</div>
{manager.provider === 'hermes' ? (
<div className="col-span-3">
<ExternalAutomationRunTable
scope={scope}
manager={manager}
job={job}
now={now}
onFetchRuns={onFetchRuns}
onOpenRun={(run) => onOpenRun?.(manager, job, run)}
/>
</div>
) : null}
</div>
)
})}
@@ -339,15 +275,10 @@ export function ExternalAutomationManagers({
'auto.components.automations.ExternalAutomationManagers.3d58d5b67d',
'No'
)}{' '}
{manager.provider === 'hermes'
? translate(
'auto.components.automations.ExternalAutomationManagers.766abf833c',
'Hermes'
)
: translate(
'auto.components.automations.ExternalAutomationManagers.5524365227',
'OpenClaw'
)}{' '}
{translate(
'auto.components.automations.ExternalAutomationManagers.5524365227',
'OpenClaw'
)}{' '}
{translate(
'auto.components.automations.ExternalAutomationManagers.6da3bfba4b',
'automations found.'
@@ -1,312 +0,0 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: run rows are fetched from the external automation store; the loading state tracks that async request lifecycle. */
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, ChevronLeft, ChevronRight, FileText, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type {
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun
} from '../../../../shared/automations-types'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import { externalAutomationScopeKey } from './external-automation-scope-keys'
import {
formatExternalDate,
getExternalRunStatusLabel,
getExternalRunStatusVariant
} from './external-automation-display'
import {
createExternalAutomationRunTableState,
resolveExternalAutomationFetchedRuns,
resolveExternalAutomationRunTableState,
updateExternalAutomationRunTablePage
} from './external-automation-run-table-state'
import { translate } from '@/i18n/i18n'
const PAGE_SIZE = 8
export type ExternalAutomationRunPage = {
runs: ExternalAutomationRun[]
totalCount?: number
}
export type FetchExternalAutomationRuns = (input: {
/** The host the rows are read from; a manager ID alone cannot name one. */
scope: ExternalAutomationScope
manager: ExternalAutomationManager
job: ExternalAutomationJob
page: number
pageSize: number
}) => Promise<ExternalAutomationRun[] | ExternalAutomationRunPage>
type ExternalAutomationRunTableProps = {
scope: ExternalAutomationScope
manager: ExternalAutomationManager
job: ExternalAutomationJob
now: number
onFetchRuns?: FetchExternalAutomationRuns
onOpenRun?: (run: ExternalAutomationRun) => void
}
function getRunSummary(run: ExternalAutomationRun): string {
return run.error ?? run.outputPreview ?? 'No output preview'
}
function normalizeRunPage(
result: ExternalAutomationRun[] | ExternalAutomationRunPage
): ExternalAutomationRunPage {
if (Array.isArray(result)) {
return { runs: result }
}
return result
}
export function ExternalAutomationRunTable({
scope,
manager,
job,
now,
onFetchRuns,
onOpenRun
}: ExternalAutomationRunTableProps): React.JSX.Element {
const [tableState, setTableState] = useState(() => createExternalAutomationRunTableState(job))
const [isLoading, setIsLoading] = useState(false)
const scopeRef = useRef(scope)
const managerRef = useRef(manager)
const jobRef = useRef(job)
scopeRef.current = scope
managerRef.current = manager
jobRef.current = job
// Refetch when the host changes, not just the job: the same manager and job ID
// can be a different machine's cron entry.
const scopeKey = externalAutomationScopeKey(scope)
const resolvedTableState = resolveExternalAutomationRunTableState(tableState, job)
if (resolvedTableState !== tableState) {
// Why: manager rows can switch jobs while the table stays mounted; reset
// before paint so stale fetched rows/selection never flash for the new job.
setTableState(resolvedTableState)
}
const { page, selectedRunId, fetchedRuns, fetchedTotalCount, fetchError } = resolvedTableState
useEffect(() => {
if (!onFetchRuns) {
return
}
let cancelled = false
setIsLoading(true)
setTableState((current) => ({
...resolveExternalAutomationRunTableState(current, jobRef.current),
fetchError: null
}))
void onFetchRuns({
scope: scopeRef.current,
manager: managerRef.current,
job: jobRef.current,
page,
pageSize: PAGE_SIZE
})
.then((result) => {
if (cancelled) {
return
}
const nextPage = normalizeRunPage(result)
setTableState((current) =>
resolveExternalAutomationFetchedRuns(current, jobRef.current, nextPage)
)
})
.catch((error) => {
if (!cancelled) {
setTableState((current) => ({
...resolveExternalAutomationRunTableState(current, jobRef.current),
fetchedRuns: null,
fetchedTotalCount: null,
fetchError: error instanceof Error ? error.message : 'Failed to load runs.'
}))
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false)
}
})
return () => {
cancelled = true
}
}, [job.id, manager.id, onFetchRuns, page, scopeKey])
const fallbackRuns = job.runs
const visibleRuns = onFetchRuns
? (fetchedRuns ?? fallbackRuns.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE))
: fallbackRuns.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
const totalCount = onFetchRuns ? (fetchedTotalCount ?? job.runCount) : job.runCount
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const selectedRun = useMemo(
() =>
visibleRuns.find((run) => run.id === selectedRunId) ??
fallbackRuns.find((run) => run.id === selectedRunId) ??
visibleRuns[0] ??
null,
[fallbackRuns, selectedRunId, visibleRuns]
)
const hasVisibleRuns = visibleRuns.length > 0
const pageStart = totalCount === 0 || !hasVisibleRuns ? 0 : page * PAGE_SIZE + 1
const pageEnd = Math.min(totalCount, page * PAGE_SIZE + visibleRuns.length)
const handlePageChange = (nextPage: number): void => {
setTableState((current) => updateExternalAutomationRunTablePage(current, job, nextPage))
}
return (
<div className="mt-2 rounded-md border border-border/50 bg-background/50">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<div className="text-xs font-medium">
{translate('auto.components.automations.ExternalAutomationRunTable.2d4388a908', 'Runs')}
</div>
{isLoading ? <Loader2 className="size-3.5 animate-spin text-muted-foreground" /> : null}
{fetchError ? (
<Tooltip>
<TooltipTrigger asChild>
<AlertCircle className="size-3.5 text-destructive" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{fetchError}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{totalCount}{' '}
{totalCount === 1
? translate('auto.components.automations.ExternalAutomationRunTable.872d032d05', 'run')
: translate(
'auto.components.automations.ExternalAutomationRunTable.d5527d8fe7',
'runs'
)}
</div>
</div>
{hasVisibleRuns ? (
<div>
<div className="min-w-0 border-b border-border/50">
<div className="grid grid-cols-[minmax(7.5rem,.45fr)_minmax(0,1fr)_auto] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
<span>
{translate(
'auto.components.automations.ExternalAutomationRunTable.d4b34feb66',
'Run time'
)}
</span>
<span>
{translate(
'auto.components.automations.ExternalAutomationRunTable.a813df9808',
'Preview'
)}
</span>
<span>
{translate(
'auto.components.automations.ExternalAutomationRunTable.be551397ca',
'Status'
)}
</span>
</div>
<div className="divide-y divide-border/50">
{visibleRuns.map((run) => (
<button
key={run.id}
type="button"
data-current={selectedRun?.id === run.id}
onClick={() => {
setTableState((current) => ({
...resolveExternalAutomationRunTableState(current, job),
selectedRunId: run.id
}))
onOpenRun?.(run)
}}
className={cn(
'grid w-full grid-cols-[minmax(7.5rem,.45fr)_minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
selectedRun?.id === run.id && 'bg-accent text-accent-foreground'
)}
>
<span className="min-w-0">
<span className="block truncate text-xs">
{formatExternalDate(run.runAt, now)}
</span>
{run.outputPath ? (
<span className="mt-0.5 block truncate font-mono text-[11px] text-muted-foreground">
{run.outputPath}
</span>
) : null}
</span>
<span className="min-w-0 truncate text-xs text-muted-foreground">
{getRunSummary(run)}
</span>
<Badge variant={getExternalRunStatusVariant(run)}>
{getExternalRunStatusLabel(run)}
</Badge>
</button>
))}
</div>
</div>
</div>
) : (
<div className="px-3 py-4 text-sm text-muted-foreground">
{isLoading
? translate(
'auto.components.automations.ExternalAutomationRunTable.8ea934cacf',
'Loading runs...'
)
: translate(
'auto.components.automations.ExternalAutomationRunTable.9c080765ff',
'No Hermes runs found yet.'
)}
</div>
)}
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<FileText className="size-3.5" />
<span>
{pageStart}-{pageEnd}{' '}
{translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}{' '}
{totalCount}
</span>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.automations.ExternalAutomationRunTable.52d468a0b8',
'Previous run page'
)}
disabled={page === 0 || isLoading}
onClick={() => handlePageChange(Math.max(0, page - 1))}
>
<ChevronLeft className="size-3.5" />
</Button>
<div className="min-w-14 text-center text-xs text-muted-foreground">
{page + 1} / {totalPages}
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.automations.ExternalAutomationRunTable.0ba9c0a95c',
'Next run page'
)}
disabled={page >= totalPages - 1 || isLoading}
onClick={() => handlePageChange(Math.min(totalPages - 1, page + 1))}
>
<ChevronRight className="size-3.5" />
</Button>
</div>
</div>
</div>
)
}
@@ -1,286 +0,0 @@
import React, { useMemo, useState } from 'react'
import {
AlertTriangle,
CalendarClock,
CheckCircle2,
ChevronDown,
ChevronRight,
Clock,
Fingerprint,
MessageSquare,
Sparkles,
Terminal
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { isRunnableAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import { translate } from '@/i18n/i18n'
import { parseHermesOutput, type ParsedHermesSection } from './hermes-cron-output-parse'
function isPromptSection(section: ParsedHermesSection): boolean {
return /^prompt$/i.test(section.heading.trim())
}
function isResponseSection(section: ParsedHermesSection): boolean {
return /^response$/i.test(section.heading.trim())
}
function isErrorSection(section: ParsedHermesSection): boolean {
return /^error$/i.test(section.heading.trim())
}
function getScheduleDisplay(value: string): string | null {
const trimmed = value.trim()
if (!isRunnableAutomationSchedule(trimmed)) {
return null
}
return formatUiAutomationSchedule(trimmed)
}
function isScheduleMetadataLabel(label: string): boolean {
return /^(?:schedule|cron schedule|cron)$/i.test(label.trim())
}
function getMetadataDisplayLabel(label: string): string {
return isScheduleMetadataLabel(label) ? 'Schedule' : label
}
type MetadataIconStyle = { icon: LucideIcon; iconClass: string; ringClass: string }
function getMetadataIconStyle(label: string): MetadataIconStyle {
const normalized = label.toLowerCase()
if (/(^|\s)(job\s*id|id)(\s|$)/.test(normalized)) {
return {
icon: Fingerprint,
iconClass: 'text-violet-400',
ringClass: 'bg-violet-500/10 ring-1 ring-violet-500/30'
}
}
if (/time|run/.test(normalized)) {
return {
icon: Clock,
iconClass: 'text-sky-400',
ringClass: 'bg-sky-500/10 ring-1 ring-sky-500/30'
}
}
if (/schedule|cron/.test(normalized)) {
return {
icon: CalendarClock,
iconClass: 'text-amber-400',
ringClass: 'bg-amber-500/10 ring-1 ring-amber-500/30'
}
}
return {
icon: Sparkles,
iconClass: 'text-muted-foreground',
ringClass: 'bg-muted/40 ring-1 ring-border/60'
}
}
type CollapsibleSectionProps = {
title: string
defaultOpen?: boolean
children: React.ReactNode
tone?: 'default' | 'muted'
icon?: LucideIcon
iconClass?: string
}
function CollapsibleSection({
title,
defaultOpen = false,
children,
tone = 'default',
icon: Icon,
iconClass
}: CollapsibleSectionProps): React.JSX.Element {
const [open, setOpen] = useState(defaultOpen)
return (
<section
className={cn(
'overflow-hidden rounded-lg border border-border/50',
tone === 'muted' ? 'bg-muted/15' : 'bg-background'
)}
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-foreground transition-colors hover:bg-muted/40"
>
{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
{Icon ? <Icon className={cn('size-3.5', iconClass ?? 'text-muted-foreground')} /> : null}
{title}
</button>
{open ? <div className="border-t border-border/50 px-4 py-3">{children}</div> : null}
</section>
)
}
type SectionCardProps = {
title: string
accent?: 'response' | 'error' | 'default'
children: React.ReactNode
}
function SectionCard({ title, accent = 'default', children }: SectionCardProps): React.JSX.Element {
const Icon = accent === 'error' ? AlertTriangle : CheckCircle2
return (
<section
className={cn(
'relative overflow-hidden rounded-lg border shadow-sm',
accent === 'error'
? 'border-rose-500/30 bg-gradient-to-br from-rose-500/10 via-background to-background'
: accent === 'response'
? 'border-emerald-500/25 bg-gradient-to-br from-emerald-500/5 via-background to-background'
: 'border-border/50 bg-background'
)}
>
<header
className={cn(
'flex items-center gap-2 border-b px-4 py-2.5 text-xs font-semibold uppercase tracking-wide',
accent === 'error'
? 'border-rose-500/20 text-rose-700 dark:text-rose-300'
: accent === 'response'
? 'border-emerald-500/20 text-emerald-700 dark:text-emerald-300'
: 'border-border/50 text-foreground'
)}
>
{accent !== 'default' ? <Icon className="size-3.5" /> : null}
{title}
</header>
<div className="px-4 py-3">{children}</div>
</section>
)
}
function MetadataValue({ label, value }: { label: string; value: string }): React.JSX.Element {
const scheduleDisplay = isScheduleMetadataLabel(label) ? getScheduleDisplay(value) : null
if (!scheduleDisplay) {
return <dd className="mt-0.5 break-all font-mono text-xs text-foreground">{value}</dd>
}
return (
<dd className="mt-0.5 break-words text-xs font-medium text-foreground">{scheduleDisplay}</dd>
)
}
export function HermesCronOutputView({ content }: { content: string }): React.JSX.Element {
const parsed = useMemo(() => parseHermesOutput(content), [content])
const responseSection = parsed.sections.find(isResponseSection)
const errorSection = parsed.sections.find(isErrorSection)
const promptSection = parsed.sections.find(isPromptSection)
const otherSections = parsed.sections.filter(
(section) =>
!isResponseSection(section) && !isErrorSection(section) && !isPromptSection(section)
)
const hasStructure =
parsed.metadata.length > 0 || [responseSection, errorSection, promptSection].some(Boolean)
if (!hasStructure) {
return (
<CommentMarkdown
variant="document"
content={content}
className="text-sm leading-relaxed text-foreground"
/>
)
}
return (
<div className="space-y-4">
{parsed.metadata.length > 0 ? (
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{parsed.metadata.map((entry) => {
const { icon: Icon, iconClass, ringClass } = getMetadataIconStyle(entry.label)
return (
<div
key={entry.label}
className="flex items-start gap-3 rounded-lg border border-border/50 bg-muted/15 px-3 py-2.5"
>
<span
className={cn(
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md',
ringClass
)}
>
<Icon className={cn('size-3.5', iconClass)} />
</span>
<div className="min-w-0 flex-1">
<dt className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{getMetadataDisplayLabel(entry.label)}
</dt>
<MetadataValue label={entry.label} value={entry.value} />
</div>
</div>
)
})}
</dl>
) : null}
{errorSection ? (
<SectionCard
title={translate('auto.components.automations.HermesCronOutputView.05affc68e3', 'Error')}
accent="error"
>
<CommentMarkdown
variant="document"
content={errorSection.body}
className="text-sm leading-relaxed text-foreground"
/>
</SectionCard>
) : null}
{responseSection ? (
<SectionCard
title={translate(
'auto.components.automations.HermesCronOutputView.4557213074',
'Response'
)}
accent="response"
>
<CommentMarkdown
variant="document"
content={responseSection.body}
className="text-sm leading-relaxed text-foreground"
/>
</SectionCard>
) : null}
{promptSection ? (
<CollapsibleSection
title={translate('auto.components.automations.HermesCronOutputView.e27c716b43', 'Prompt')}
tone="muted"
icon={MessageSquare}
iconClass="text-indigo-700 dark:text-indigo-400"
>
<CommentMarkdown
variant="document"
content={promptSection.body}
className="text-sm leading-relaxed text-foreground/90"
/>
</CollapsibleSection>
) : null}
{otherSections.map((section) => (
<CollapsibleSection
key={section.heading}
title={section.heading}
tone="muted"
icon={Terminal}
iconClass="text-muted-foreground"
>
<CommentMarkdown
variant="document"
content={section.body}
className="text-sm leading-relaxed text-foreground/90"
/>
</CollapsibleSection>
))}
</div>
)
}
@@ -1,6 +1,5 @@
import { getAgentCatalog } from '@/lib/agent-catalog'
import type { AutomationPrecheck } from '../../../../shared/automations-types'
import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences'
import type { Worktree } from '../../../../shared/worktree/types'
import {
hasExtraAgentArgs,
@@ -38,19 +37,6 @@ export function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck |
}
}
export function buildHermesCronSchedule(draft: AutomationDraft): string {
if (draft.preset === 'custom') {
return draft.customSchedule.trim()
}
const { hour, minute } = parseDraftTime(draft.time)
return buildAutomationCronSchedule({
preset: draft.preset,
hour,
minute,
dayOfWeek: Number(draft.dayOfWeek)
})
}
export function getAgentLabel(agentId: string): string {
return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId
}
@@ -1,15 +1,6 @@
/**
* Turns a saved record back into the editor's draft shape.
*
* Both builders are total: a schedule the editor cannot represent produces a
* warning rather than a silently rewritten one, because saving would otherwise
* replace a working cron the user never chose to change.
*/
import type { Automation, ExternalAutomationJob } from '../../../../shared/automations-types'
import type { Automation } from '../../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
import {
isRunnableAutomationCronSchedule,
isRunnableAutomationSchedule,
tryParseAutomationRrule
} from '../../../../shared/automation-schedule-parsing'
@@ -48,34 +39,3 @@ export function buildAutomationEditDraft(automation: Automation): AutomationDraf
: 'This automation has an unsupported saved schedule. Pick a supported schedule before saving changes.'
}
}
export function buildExternalAutomationEditDraft(
job: ExternalAutomationJob,
placement: { projectId: string; workspaceId: string }
): AutomationDraft {
const rawSchedule = job.rawSchedule?.trim() ?? ''
const hasCustomSchedule = isRunnableAutomationCronSchedule(rawSchedule)
return {
name: job.name,
prompt: job.prompt ?? job.promptPreview,
agentId: 'hermes',
extraAgentArgs: '',
projectId: placement.projectId,
workspaceMode: 'existing',
workspaceId: placement.workspaceId,
baseBranch: '',
setupDecision: undefined,
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: hasCustomSchedule ? 'custom' : 'weekdays',
time: AUTOMATION_DEFAULT_TIME,
dayOfWeek: '1',
customSchedule: hasCustomSchedule ? rawSchedule : '',
savedSchedule: rawSchedule || null,
missedRunGraceMinutes: '720',
scheduleWarning: hasCustomSchedule
? null
: 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.'
}
}
@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ExternalAutomationTarget } from '../../../../shared/automations-types'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import { repoMatchesExternalAutomationTarget } from './automation-external-target-match'
function repo(
overrides: Partial<Pick<Repo, 'connectionId' | 'executionHostId'>> = {}
): Pick<Repo, 'connectionId' | 'executionHostId'> {
return { connectionId: undefined, executionHostId: undefined, ...overrides }
}
const sshTarget: ExternalAutomationTarget = { type: 'ssh', connectionId: 'conn-1' }
const localTarget: ExternalAutomationTarget = { type: 'local' }
describe('repoMatchesExternalAutomationTarget', () => {
it('matches a plain local repo against the local target', () => {
expect(repoMatchesExternalAutomationTarget(repo(), localTarget)).toBe(true)
})
it('derives the ssh host from connectionId when executionHostId is unset', () => {
expect(repoMatchesExternalAutomationTarget(repo({ connectionId: 'conn-1' }), sshTarget)).toBe(
true
)
})
it('rejects an ssh repo whose executionHostId points at another host despite matching connectionId', () => {
expect(
repoMatchesExternalAutomationTarget(
repo({ connectionId: 'conn-1', executionHostId: toSshExecutionHostId('conn-2') }),
sshTarget
)
).toBe(false)
})
it('accepts an explicit executionHostId equal to the target host id', () => {
expect(
repoMatchesExternalAutomationTarget(
repo({ connectionId: 'other-conn', executionHostId: toSshExecutionHostId('conn-1') }),
sshTarget
)
).toBe(true)
})
it('treats a runtime-owned execution host as not local even without a connectionId', () => {
expect(
repoMatchesExternalAutomationTarget(repo({ executionHostId: 'runtime:env-1' }), localTarget)
).toBe(false)
})
})
@@ -1,20 +0,0 @@
import type { ExternalAutomationTarget } from '../../../../shared/automations-types'
import {
getRepoExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId
} from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
// Why: connectionId alone can disagree with the repo's pinned executionHostId;
// the execution host is the authority for which Hermes target owns a repo.
export function repoMatchesExternalAutomationTarget(
repo: Pick<Repo, 'connectionId' | 'executionHostId'>,
target: ExternalAutomationTarget
): boolean {
const repoHostId = getRepoExecutionHostId(repo)
if (target.type === 'local') {
return parseExecutionHostId(repoHostId)?.kind === 'local'
}
return repoHostId === toSshExecutionHostId(target.connectionId)
}
@@ -1,89 +0,0 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { buildHermesCronSchedule } from './automation-draft-model'
import { externalAutomationJobKey } from './external-automation-scope-keys'
import type { AutomationSaveContext } from './automation-save-context'
export async function saveHermesAutomation({
store,
local,
list,
pageRefresh
}: AutomationSaveContext): Promise<void> {
const { repoMap, worktreeMap } = store
const {
draft,
editingExternalTarget,
setCreateOpen,
setEditingExternalTarget,
selectExternalKey
} = local
const repo = repoMap.get(draft.projectId)
const selectedWorktree = worktreeMap.get(draft.workspaceId) ?? null
if (!repo || !selectedWorktree) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.32534e7c9c',
'Choose an available workspace before saving.'
)
)
return
}
const scopedExternal = list.scopedExternal
const scope =
editingExternalTarget?.scope ?? scopedExternal.createScope(repo.connectionId ?? null)
const repoTargetMatches = scope
? scope.owner.selector.kind === 'ssh'
? repo.connectionId === scope.owner.selector.targetId
: !repo.connectionId
: false
if (!scope || !repoTargetMatches) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.e431bb85d4',
'Choose a workspace on the same host as this Hermes automation.'
)
)
return
}
const schedule = buildHermesCronSchedule(draft)
const fields = {
name: draft.name,
prompt: draft.prompt,
schedule,
workdir: selectedWorktree.path
}
await scopedExternal.saveExternalAutomation(
scope,
{
name: fields.name,
prompt: fields.prompt,
schedule: fields.schedule,
workdir: fields.workdir
},
editingExternalTarget?.job.id ?? null
)
if (!editingExternalTarget) {
useAppStore.getState().recordFeatureInteraction('automation-created')
}
await pageRefresh.refresh()
setCreateOpen(false)
setEditingExternalTarget(null)
selectExternalKey(
editingExternalTarget
? externalAutomationJobKey(editingExternalTarget.scope, editingExternalTarget.job.id)
: null
)
toast.success(
editingExternalTarget
? translate(
'auto.components.automations.AutomationsPage.08efc3ae12',
'Hermes automation updated.'
)
: translate(
'auto.components.automations.AutomationsPage.77b81bc4ac',
'Hermes automation created.'
)
)
}
@@ -69,7 +69,7 @@ function makeJob(overrides: Partial<ExternalAutomationJob> = {}): ExternalAutoma
return {
id: 'job-1',
managerId: 'manager-1',
provider: 'hermes',
provider: 'openclaw',
name: 'Digest',
schedule: '0 9 * * 1-5',
rawSchedule: null,
@@ -84,8 +84,8 @@ describe('automation search row fields', () => {
throw new Error('fixture produced no external entry')
}
const fields = buildExternalAutomationSearchFields(entry)
expect(fields.name).toBe('Hermes job')
expect(fields.agent).toBe('Hermes')
expect(fields.name).toBe('OpenClaw job')
expect(fields.agent).toBe('OpenClaw')
expect(fields.host).toBe('This computer')
expect(fields.prompt).toBe('Sweep')
})
@@ -52,8 +52,8 @@ function makeExternalEntry(
): ExternalAutomationListEntry {
const manager: ExternalAutomationManager = {
id: 'manager-1',
provider: 'hermes',
label: 'Local Hermes',
provider: 'openclaw',
label: 'Local OpenClaw',
targetLabel: 'Local',
target: { type: 'local' },
status: 'available',
@@ -64,7 +64,7 @@ function makeExternalEntry(
const job: ExternalAutomationJob = {
id: 'job-1',
managerId: manager.id,
provider: 'hermes',
provider: 'openclaw',
name: 'Alpha digest',
schedule: '0 9 * * 1-5',
rawSchedule: null,
@@ -35,7 +35,6 @@ export async function saveOrcaAutomation(
const { repos, projectHostSetups } = store
const {
draft,
createTarget,
editingAutomationId,
editingRowKey,
editingDestination,
@@ -98,7 +97,6 @@ export async function saveOrcaAutomation(
)
: projectHostSetups
let setupDecision = resolveAutomationSetupDecisionForSave({
createTarget,
workspaceMode: draft.workspaceMode,
repoId: draft.projectId,
repos: reposForDraft,
@@ -1,9 +1,3 @@
import type {
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun
} from '../../../../shared/automations-types'
/** Detail-pane tab shared by the page, its list panel, and the detail pane. */
export type AutomationPaneTab = 'overview' | 'runs'
@@ -11,10 +5,3 @@ export type AutomationPaneTab = 'overview' | 'runs'
export type AutomationsPageView = 'automations' | 'runs' | 'run'
export type AutomationRunPageOrigin = 'runs' | 'automation'
/** External run opened as a full page inside the detail pane. */
export type SelectedExternalRunPage = {
manager: ExternalAutomationManager
job: ExternalAutomationJob
run: ExternalAutomationRun
}
@@ -1,9 +1,6 @@
import { toast } from 'sonner'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import {
isValidAutomationCronSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedule-parsing'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
import { translate } from '@/i18n/i18n'
import { acceptsAutomationDraftSchedule } from './automation-schedule-input-gate'
import {
@@ -11,29 +8,19 @@ import {
getDraftExtraAgentArgsError,
parseDraftTime
} from './automation-draft-model'
import { saveHermesAutomation } from './automation-hermes-save'
import { saveOrcaAutomation } from './automation-orca-save'
import type { AutomationSaveContext } from './automation-save-context'
/** Validates editor input then delegates the provider-specific save transaction. */
/** Validates editor input then runs the Orca save transaction. */
export function createAutomationSaveAction(context: AutomationSaveContext) {
return async function saveAutomation(now = Date.now()): Promise<void> {
const { store, local, destinationForm, destination } = context
const { settings } = store
const {
draft,
editingAutomationId,
createTarget,
editingExternalTarget,
setIsSaving,
setEditorNotice
} = local
const isHermesSave =
editingAutomationId === null && (createTarget === 'hermes' || editingExternalTarget !== null)
const { draft, editingAutomationId, setIsSaving, setEditorNotice } = local
const { hour, minute } = parseDraftTime(draft.time)
if (
!draft.projectId ||
((draft.workspaceMode === 'existing' || isHermesSave) && !draft.workspaceId) ||
(draft.workspaceMode === 'existing' && !draft.workspaceId) ||
!draft.prompt.trim()
) {
toast.error(
@@ -53,15 +40,12 @@ export function createAutomationSaveAction(context: AutomationSaveContext) {
)
return
}
const validateAdvancedSchedule = isHermesSave
? isValidAutomationCronSchedule
: isValidAutomationSchedule
if (
draft.preset === 'custom' &&
!acceptsAutomationDraftSchedule({
customSchedule: draft.customSchedule,
savedRrule: draft.savedSchedule,
validate: validateAdvancedSchedule
validate: isValidAutomationSchedule
})
) {
toast.error(
@@ -74,7 +58,6 @@ export function createAutomationSaveAction(context: AutomationSaveContext) {
}
if (
editingAutomationId === null &&
!isHermesSave &&
!isTuiAgentEnabled(draft.agentId, settings?.disabledTuiAgents)
) {
toast.error(
@@ -85,17 +68,15 @@ export function createAutomationSaveAction(context: AutomationSaveContext) {
)
return
}
if (!isHermesSave) {
const extrasError = draftExtraAgentArgsNeedFreshSession(draft)
? translate(
'auto.components.automations.extraAgentArgs.needsFreshSession',
'Extra arguments require a fresh session for every run.'
)
: getDraftExtraAgentArgsError(draft)
if (extrasError) {
toast.error(extrasError)
return
}
const extrasError = draftExtraAgentArgsNeedFreshSession(draft)
? translate(
'auto.components.automations.extraAgentArgs.needsFreshSession',
'Extra arguments require a fresh session for every run.'
)
: getDraftExtraAgentArgsError(draft)
if (extrasError) {
toast.error(extrasError)
return
}
setIsSaving(true)
try {
@@ -111,20 +92,15 @@ export function createAutomationSaveAction(context: AutomationSaveContext) {
)
return
}
if (!isHermesSave && editingAutomationId === null) {
if (editingAutomationId === null) {
const checked = destination.createDestination.check(draft.projectId)
if (!checked.ok) {
setEditorNotice(checked.notice)
return
}
}
await (isHermesSave
? saveHermesAutomation(context)
: saveOrcaAutomation(context, { hour, minute, now }))
await saveOrcaAutomation(context, { hour, minute, now })
} catch (error) {
if (isHermesSave) {
await context.pageRefresh.refresh().catch(() => undefined)
}
toast.error(
error instanceof Error
? error.message
@@ -45,7 +45,6 @@ describe('automation setup decision defaults', () => {
] as const)('maps %s setup policy to %s', (setupRunPolicy, expectedDecision) => {
expect(
getVisibleAutomationSetupDecision({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -75,21 +74,12 @@ describe('automation setup decision defaults', () => {
expect(
getVisibleAutomationSetupDecision({
...baseArgs,
createTarget: 'orca',
workspaceMode: 'existing'
})
).toBeUndefined()
expect(
getVisibleAutomationSetupDecision({
...baseArgs,
createTarget: 'hermes',
workspaceMode: 'new_per_run'
})
).toBeUndefined()
expect(
getVisibleAutomationSetupDecision({
...baseArgs,
createTarget: 'orca',
workspaceMode: 'new_per_run',
repos: [
{
@@ -104,7 +94,6 @@ describe('automation setup decision defaults', () => {
it('uses ready project-host setup hook settings before repo fallback', () => {
expect(
getVisibleAutomationSetupDecision({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [repo],
@@ -125,7 +114,6 @@ describe('automation setup decision defaults', () => {
it('shows setup choice for shared orca.yaml setup and default tabs', () => {
expect(
getVisibleAutomationSetupDecision({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -148,7 +136,6 @@ describe('automation setup decision defaults', () => {
expect(
getVisibleAutomationSetupDecision({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -173,7 +160,6 @@ describe('automation setup decision defaults', () => {
it('omits setup decision when no setup source is visible', () => {
expect(
resolveAutomationSetupDecisionForSave({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -193,7 +179,6 @@ describe('automation setup decision defaults', () => {
).toBeUndefined()
expect(
resolveAutomationSetupDecisionForSave({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -214,7 +199,6 @@ describe('automation setup decision defaults', () => {
expect(
resolveAutomationSetupDecisionForSave({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -237,7 +221,6 @@ describe('automation setup decision defaults', () => {
it('fails closed when saving before shared hook inspection is available', () => {
expect(
resolveAutomationSetupDecisionForSave({
createTarget: 'orca',
workspaceMode: 'new_per_run',
repoId: 'repo-1',
repos: [
@@ -44,14 +44,13 @@ export function getAutomationSetupDefaultDecision(
}
export function getVisibleAutomationSetupDecision(args: {
createTarget: 'orca' | 'hermes'
workspaceMode: AutomationWorkspaceMode
repoId: string
repos: readonly Repo[]
projectHostSetups: readonly ProjectHostSetup[]
yamlHooks?: OrcaHooks | null
}): Extract<SetupDecision, 'run' | 'skip'> | undefined {
if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') {
if (args.workspaceMode !== 'new_per_run') {
return undefined
}
return getAutomationSetupDefaultDecision(
@@ -60,7 +59,6 @@ export function getVisibleAutomationSetupDecision(args: {
}
export function resolveAutomationSetupDecisionForSave(args: {
createTarget: 'orca' | 'hermes'
workspaceMode: AutomationWorkspaceMode
repoId: string
repos: readonly Repo[]
@@ -68,7 +66,7 @@ export function resolveAutomationSetupDecisionForSave(args: {
yamlHooks?: OrcaHooks | null
draftSetupDecision: Extract<SetupDecision, 'run' | 'skip'> | undefined
}): Extract<SetupDecision, 'run' | 'skip'> | undefined {
if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') {
if (args.workspaceMode !== 'new_per_run') {
return undefined
}
@@ -121,9 +121,9 @@ export function makeExternalManager(
overrides: Partial<ExternalAutomationManager> = {}
): ExternalAutomationManager {
return {
id: 'hermes:local',
provider: 'hermes',
label: 'Hermes',
id: 'openclaw:local',
provider: 'openclaw',
label: 'OpenClaw',
targetLabel: 'This computer',
target: { type: 'local' },
status: 'available',
@@ -132,9 +132,9 @@ export function makeExternalManager(
jobs: [
{
id: 'job-1',
managerId: 'hermes:local',
provider: 'hermes',
name: 'Hermes job',
managerId: 'openclaw:local',
provider: 'openclaw',
name: 'OpenClaw job',
schedule: 'Daily at 09:00',
rawSchedule: '0 9 * * *',
enabled: true,
@@ -163,7 +163,7 @@ export function makeExternalAutomationScope(
authority: { kind: 'desktop' },
selector: { kind: 'self' }
},
provider: 'hermes',
provider: 'openclaw',
...overrides
}
}
@@ -45,11 +45,6 @@ export const RUNTIME_WORKSPACE_ID = RUNTIME_WORKSPACE_ID_FIXTURE
export type ListPanelProps = {
sortedListItems: readonly AutomationListViewItem[]
selectedExternal: ExternalAutomationListEntry | null
openEditExternalDialog: (
manager: ExternalAutomationListEntry['manager'],
job: ExternalAutomationListEntry['job'],
scope: ExternalAutomationListEntry['scope']
) => void
externalActionKey: string | null
requestExternalAction: (
manager: ExternalAutomationListEntry['manager'],
@@ -87,13 +82,6 @@ export type DetailPaneProps = {
toggleAutomation: (automation: Automation) => void
requestDeleteAutomation: (automation: Automation) => void
openEditDialog: (automation: Automation) => void
fetchExternalAutomationRuns: (input: {
scope: ExternalAutomationListEntry['scope']
manager: ExternalAutomationListEntry['manager']
job: ExternalAutomationListEntry['job']
page: number
pageSize: number
}) => Promise<unknown>
}
export type EditorDialogProps = {
@@ -286,9 +274,6 @@ export const api = {
'runNow',
'listScoped',
'listExternalManagerForOwner',
'listExternalRunsForOwner',
'createExternalForOwner',
'updateExternalForOwner',
'runExternalActionForOwner',
'retainExternalScopes'
),
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isMissingExternalRunsApiError } from './external-automation-display'
import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../../../shared/external-automation-scope'
describe('isMissingExternalRunsApiError', () => {
it('recognizes the relay code carried in the message', () => {
// Electron IPC keeps only the message, so the code rides inside it.
expect(
isMissingExternalRunsApiError(
new Error(
`Error invoking remote method: This host does not report external automation run history.: ${EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported}`
)
)
).toBe(true)
})
it('still recognizes hosts that answer with no code at all', () => {
expect(
isMissingExternalRunsApiError(new Error("No handler registered for 'automations:listRuns'"))
).toBe(true)
expect(isMissingExternalRunsApiError(new Error('listExternalRuns is not a function'))).toBe(
true
)
})
it('does not treat an ordinary relay failure as a missing endpoint', () => {
// Falling back here would render a truncated run list as if it were complete.
expect(isMissingExternalRunsApiError(new Error('SSH target "ssh-1" is not connected.'))).toBe(
false
)
expect(isMissingExternalRunsApiError(new Error('socket hang up'))).toBe(false)
})
})
@@ -1,10 +1,4 @@
import React from 'react'
import type { Badge } from '@/components/ui/badge'
import type {
ExternalAutomationManager,
ExternalAutomationRun
} from '../../../../shared/automations-types'
import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../../../shared/external-automation-scope'
import type { ExternalAutomationManager } from '../../../../shared/automations-types'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
export function formatExternalDate(value: string | null, now: number): string {
@@ -18,49 +12,10 @@ export function formatExternalDate(value: string | null, now: number): string {
return formatAutomationDateTimeWithRelative(parsed, now)
}
export function getExternalProviderLabel(manager: ExternalAutomationManager): string {
return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'
export function getExternalProviderLabel(_manager: ExternalAutomationManager): string {
return 'OpenClaw'
}
export function getExternalTargetKindLabel(manager: ExternalAutomationManager): string {
return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
}
export function getExternalRunStatusLabel(run: ExternalAutomationRun): string {
switch (run.status) {
case 'completed':
return 'Completed'
case 'failed':
return 'Failed'
case 'unknown':
return 'Unknown'
}
}
export function getExternalRunStatusVariant(
run: ExternalAutomationRun
): React.ComponentProps<typeof Badge>['variant'] {
switch (run.status) {
case 'completed':
return 'secondary'
case 'failed':
return 'destructive'
case 'unknown':
return 'outline'
}
}
export function getExternalRunContent(run: ExternalAutomationRun): string {
return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.'
}
export function isMissingExternalRunsApiError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
// The relay path now reports a code, which is exact. The patterns stay for the
// hosts that answer with no code at all: an unrouted IPC channel, and relays
// predating the code. Neither can be tightened without losing those hosts.
return (
message.includes(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported) ||
/listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message)
)
}
@@ -5,9 +5,9 @@ import type { ScopedExternalAutomationManager } from './external-automation-scop
function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager {
return {
id: 'hermes:ssh:openclaw',
provider: 'hermes',
label: 'Hermes on openclaw',
id: 'openclaw:ssh:openclaw',
provider: 'openclaw',
label: 'OpenClaw on openclaw',
targetLabel: 'openclaw',
target: { type: 'ssh', connectionId: 'openclaw' },
status: 'unavailable',
@@ -21,8 +21,8 @@ function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAu
function job(id = 'job-1'): ExternalAutomationManager['jobs'][number] {
return {
id,
managerId: 'hermes:ssh:openclaw',
provider: 'hermes',
managerId: 'openclaw:ssh:openclaw',
provider: 'openclaw',
name: 'Nightly',
schedule: '0 9 * * *',
rawSchedule: '0 9 * * *',
@@ -53,7 +53,7 @@ function scoped(
: { kind: 'runtime', environmentId, pairingRevision: 1 },
selector: { kind: 'self' }
},
provider: 'hermes'
provider: 'openclaw'
},
manager: manager(overrides)
}
@@ -73,12 +73,12 @@ describe('buildExternalAutomationListEntries', () => {
})
it('separates identical manager and job IDs reported by two authorities', () => {
// `hermes:local` names no authority, so both hosts hand back the same manager
// `openclaw:local` names no authority, so both hosts hand back the same manager
// ID. An unqualified key would make the two rows one, and the second would
// shadow the first in selection, dialogs, and React's reconciliation.
const entries = buildExternalAutomationListEntries([
scoped(null, { id: 'hermes:local', target: { type: 'local' }, jobs: [job()] }),
scoped('env-7', { id: 'hermes:local', target: { type: 'local' }, jobs: [job()] })
scoped(null, { id: 'openclaw:local', target: { type: 'local' }, jobs: [job()] }),
scoped('env-7', { id: 'openclaw:local', target: { type: 'local' }, jobs: [job()] })
])
expect(entries).toHaveLength(2)
@@ -1,108 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ExternalAutomationRun } from '../../../../shared/automations-types'
import {
createExternalAutomationRunTableState,
resolveExternalAutomationFetchedRuns,
resolveExternalAutomationRunTableState,
updateExternalAutomationRunTablePage
} from './external-automation-run-table-state'
function run(id: string): ExternalAutomationRun {
return {
id,
managerId: 'manager-1',
provider: 'hermes',
jobId: 'job-1',
runAt: '2026-05-31T10:00:00.000Z',
status: 'completed',
outputPreview: `run ${id}`,
outputContent: null,
error: null,
outputPath: null
}
}
function job(
id: string,
runs: readonly ExternalAutomationRun[]
): {
id: string
runs: readonly ExternalAutomationRun[]
} {
return {
id,
runs
}
}
describe('external automation run table state', () => {
it('resets page, selection, fetched rows, and error when the job changes', () => {
const current = {
...createExternalAutomationRunTableState(job('job-1', [run('run-1')])),
page: 2,
selectedRunId: 'run-9',
fetchedRuns: [run('run-9')],
fetchedTotalCount: 20,
fetchError: 'Failed'
}
expect(resolveExternalAutomationRunTableState(current, job('job-2', [run('run-2')]))).toEqual({
sourceJobId: 'job-2',
sourceRuns: [run('run-2')],
page: 0,
selectedRunId: 'run-2',
fetchedRuns: null,
fetchedTotalCount: null,
fetchError: null
})
})
it('resets when the same job receives a new fallback run list', () => {
const firstRuns = [run('run-1')]
const nextRuns = [run('run-2')]
const current = {
...createExternalAutomationRunTableState(job('job-1', firstRuns)),
selectedRunId: 'run-1'
}
expect(resolveExternalAutomationRunTableState(current, job('job-1', nextRuns))).toMatchObject({
sourceJobId: 'job-1',
sourceRuns: nextRuns,
page: 0,
selectedRunId: 'run-2',
fetchedRuns: null
})
})
it('preserves selected fetched runs when loading another page response for the same job', () => {
const current = {
...createExternalAutomationRunTableState(job('job-1', [run('fallback')])),
selectedRunId: 'run-2'
}
expect(
resolveExternalAutomationFetchedRuns(current, job('job-1', current.sourceRuns), {
runs: [run('run-1'), run('run-2')],
totalCount: 12
})
).toMatchObject({
selectedRunId: 'run-2',
fetchedRuns: [run('run-1'), run('run-2')],
fetchedTotalCount: 12
})
})
it('clears selection when paging manually', () => {
const current = {
...createExternalAutomationRunTableState(job('job-1', [run('run-1')])),
selectedRunId: 'run-1'
}
expect(
updateExternalAutomationRunTablePage(current, job('job-1', current.sourceRuns), 3)
).toMatchObject({
page: 3,
selectedRunId: null
})
})
})
@@ -1,75 +0,0 @@
import type { ExternalAutomationRun } from '../../../../shared/automations-types'
type ExternalAutomationRunTableJob = {
id: string
runs: readonly ExternalAutomationRun[]
}
type ExternalAutomationRunPageResult = {
runs: ExternalAutomationRun[]
totalCount?: number
}
export type ExternalAutomationRunTableState = {
sourceJobId: string
sourceRuns: readonly ExternalAutomationRun[]
page: number
selectedRunId: string | null
fetchedRuns: ExternalAutomationRun[] | null
fetchedTotalCount: number | null
fetchError: string | null
}
export function createExternalAutomationRunTableState(
job: ExternalAutomationRunTableJob
): ExternalAutomationRunTableState {
return {
sourceJobId: job.id,
sourceRuns: job.runs,
page: 0,
selectedRunId: job.runs[0]?.id ?? null,
fetchedRuns: null,
fetchedTotalCount: null,
fetchError: null
}
}
export function resolveExternalAutomationRunTableState(
state: ExternalAutomationRunTableState,
job: ExternalAutomationRunTableJob
): ExternalAutomationRunTableState {
return state.sourceJobId === job.id && state.sourceRuns === job.runs
? state
: createExternalAutomationRunTableState(job)
}
export function updateExternalAutomationRunTablePage(
state: ExternalAutomationRunTableState,
job: ExternalAutomationRunTableJob,
page: number
): ExternalAutomationRunTableState {
return {
...resolveExternalAutomationRunTableState(state, job),
page,
selectedRunId: null
}
}
export function resolveExternalAutomationFetchedRuns(
state: ExternalAutomationRunTableState,
job: ExternalAutomationRunTableJob,
result: ExternalAutomationRunPageResult
): ExternalAutomationRunTableState {
const resolved = resolveExternalAutomationRunTableState(state, job)
const selectedRunId =
resolved.selectedRunId && result.runs.some((run) => run.id === resolved.selectedRunId)
? resolved.selectedRunId
: (result.runs[0]?.id ?? null)
return {
...resolved,
fetchedRuns: result.runs,
fetchedTotalCount: result.totalCount ?? null,
selectedRunId
}
}
@@ -10,16 +10,11 @@
import type {
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationProvider,
ExternalAutomationRun
ExternalAutomationProvider
} from '../../../../shared/automations-types'
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
import {
EXTERNAL_AUTOMATION_PROVIDERS,
type ScopedExternalManagerMutationFields
} from '../../../../shared/external-automation-scope'
import { EXTERNAL_AUTOMATION_PROVIDERS } from '../../../../shared/external-automation-scope'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import { resolveExternalAutomationScopeGate } from './external-automation-scope-gating'
@@ -31,7 +26,7 @@ export type ExternalAutomationScope = {
/**
* A discovered manager still carrying the scope it was listed under.
*
* Why paired rather than looked up: a manager ID is `hermes:local` on every host
* Why paired rather than looked up: a manager ID is `openclaw:local` on every host
* that has no SSH target, so it does not name an authority. Recovering the scope
* from the ID would collide for exactly the hosts a scope is meant to separate.
*/
@@ -112,45 +107,6 @@ export async function listScopedExternalAutomationManagers(
return { managers, failures }
}
/**
* The desktop host that owns a repo's external managers. Null when no owner
* could be captured for it, which disables creation rather than defaulting one.
*/
export function desktopExternalAutomationOwner(
entries: readonly AutomationHostCatalogEntry[],
connectionId: string | null
): AutomationOwnerRef | null {
for (const entry of entries) {
if (entry.stableRef.authority.kind !== 'desktop' || !entry.owner) {
continue
}
const selector = entry.stableRef.selector
const matches =
connectionId === null
? selector.kind === 'self'
: selector.kind === 'ssh' && selector.targetId === connectionId
if (matches) {
return entry.owner
}
}
return null
}
export async function createScopedExternalAutomation(
scope: ExternalAutomationScope,
fields: ScopedExternalManagerMutationFields
): Promise<void> {
await window.api.automations.createExternalForOwner({ ...scope, ...fields })
}
export async function updateScopedExternalAutomation(
scope: ExternalAutomationScope,
jobId: string,
fields: ScopedExternalManagerMutationFields
): Promise<void> {
await window.api.automations.updateExternalForOwner({ ...scope, ...fields, jobId })
}
export async function runScopedExternalAutomationAction(
scope: ExternalAutomationScope,
jobId: string,
@@ -158,24 +114,3 @@ export async function runScopedExternalAutomationAction(
): Promise<void> {
await window.api.automations.runExternalActionForOwner({ ...scope, jobId, action })
}
export type ScopedExternalAutomationRunsPage = {
runs: readonly ExternalAutomationRun[]
totalCount: number
}
export async function listScopedExternalAutomationRuns(
scope: ExternalAutomationScope,
job: ExternalAutomationJob,
page: number,
pageSize: number
): Promise<ScopedExternalAutomationRunsPage> {
const result = await window.api.automations.listExternalRunsForOwner({
...scope,
jobId: job.id,
// The engine pages from one; the table's page index is zero-based.
page: page + 1,
pageSize
})
return { runs: result.runs, totalCount: result.total }
}
@@ -2,10 +2,8 @@ import { describe, expect, it } from 'vitest'
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
import {
externalAutomationActionKey,
externalAutomationDialogKey,
externalAutomationJobKey,
externalAutomationManagerKey,
externalAutomationRunKey,
externalAutomationScopeKey,
type ExternalAutomationScopeRef
} from './external-automation-scope-keys'
@@ -19,28 +17,22 @@ const sshOwner = (targetId = 't1', targetGeneration = 3): AutomationOwnerRef =>
selector: { kind: 'ssh', targetId, targetGeneration }
})
const local: ExternalAutomationScopeRef = { owner: localOwner, provider: 'hermes' }
const remote: ExternalAutomationScopeRef = { owner: sshOwner(), provider: 'hermes' }
const local: ExternalAutomationScopeRef = { owner: localOwner, provider: 'openclaw' }
const remote: ExternalAutomationScopeRef = { owner: sshOwner(), provider: 'openclaw' }
describe('external automation scope keys', () => {
it('separates the same provider ID across hosts', () => {
expect(externalAutomationJobKey(local, 'job-1')).not.toBe(
externalAutomationJobKey(remote, 'job-1')
)
expect(externalAutomationManagerKey(local, 'hermes:local')).not.toBe(
externalAutomationManagerKey(remote, 'hermes:local')
)
})
it('separates providers on one host', () => {
expect(externalAutomationJobKey(local, 'job-1')).not.toBe(
externalAutomationJobKey({ ...local, provider: 'openclaw' }, 'job-1')
expect(externalAutomationManagerKey(local, 'openclaw:local')).not.toBe(
externalAutomationManagerKey(remote, 'openclaw:local')
)
})
it('separates host incarnations, so a re-added target does not adopt old keys', () => {
expect(externalAutomationScopeKey(remote)).not.toBe(
externalAutomationScopeKey({ owner: sshOwner('t1', 4), provider: 'hermes' })
externalAutomationScopeKey({ owner: sshOwner('t1', 4), provider: 'openclaw' })
)
})
@@ -49,22 +41,14 @@ describe('external automation scope keys', () => {
externalAutomationScopeKey(local),
externalAutomationManagerKey(local, 'x'),
externalAutomationJobKey(local, 'x'),
externalAutomationRunKey(local, 'x', 'x'),
externalAutomationActionKey(local, 'x', 'run'),
externalAutomationDialogKey(local, 'x')
externalAutomationActionKey(local, 'x', 'run')
])
expect(kinds.size).toBe(6)
})
it('distinguishes a create dialog from a job literally named new', () => {
expect(externalAutomationDialogKey(local, null)).not.toBe(
externalAutomationDialogKey(local, 'new')
)
expect(kinds.size).toBe(4)
})
it('escapes components so a hostile ID cannot forge another key', () => {
const forged = externalAutomationJobKey(local, 'job|extra')
expect(forged).not.toBe(externalAutomationRunKey(local, 'job', 'extra'))
expect(forged).not.toBe(externalAutomationActionKey(local, 'job', 'run'))
expect(forged.split('|')).toHaveLength(4)
})
@@ -1,9 +1,9 @@
/**
* Keys for external manager rows, jobs, runs, dialogs, and in-flight actions.
* Keys for external manager rows, jobs, and in-flight actions.
*
* A provider's manager ID is unique only inside one host, so a key built from it
* alone collides the moment two hosts run the same provider — the selected row,
* the open dialog, and the disabled action button would all follow the wrong
* and the disabled action button would all follow the wrong
* host. Every key is therefore prefixed with its kind, the captured owner, and
* the provider.
*/
@@ -45,14 +45,6 @@ export function externalAutomationJobKey(scope: ExternalAutomationScopeRef, jobI
return key('job', scope, jobId)
}
export function externalAutomationRunKey(
scope: ExternalAutomationScopeRef,
jobId: string,
runId: string
): string {
return key('run', scope, jobId, runId)
}
export function externalAutomationActionKey(
scope: ExternalAutomationScopeRef,
jobId: string,
@@ -60,11 +52,3 @@ export function externalAutomationActionKey(
): string {
return key('action', scope, jobId, action)
}
/** A create dialog has no job yet, and 'new' is a segment of its own so a job literally named "new" cannot alias it. */
export function externalAutomationDialogKey(
scope: ExternalAutomationScopeRef,
jobId: string | null
): string {
return jobId === null ? key('dialog', scope, 'new') : key('dialog', scope, 'job', jobId)
}
@@ -4,9 +4,9 @@ import { getExternalAutomationActionDisabledMessage } from './external-automatio
function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager {
return {
id: 'hermes-local',
provider: 'hermes',
label: 'Hermes',
id: 'openclaw-local',
provider: 'openclaw',
label: 'OpenClaw',
targetLabel: 'Local Mac',
target: { type: 'local' },
status: 'unavailable',
@@ -21,9 +21,11 @@ describe('external automation action availability', () => {
it('explains disabled local automation actions when the source tool is missing', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({ error: 'Hermes jobs were found, but the hermes CLI is not on PATH.' })
manager: manager({
error: 'OpenClaw jobs were found, but the openclaw CLI is not on PATH.'
})
})
).toBe('Hermes jobs were found, but the hermes CLI is not on PATH.')
).toBe('OpenClaw jobs were found, but the openclaw CLI is not on PATH.')
})
it('explains disabled SSH automation actions before the host is connected', () => {
@@ -33,11 +35,11 @@ describe('external automation action availability', () => {
target: { type: 'ssh', connectionId: 'devbox' },
error: 'SSH target is not connected.'
}),
providerLabel: 'Hermes',
providerLabel: 'OpenClaw',
targetKindLabel: 'SSH host',
sshStatus: 'disconnected'
})
).toBe('Connect this ssh host before managing Hermes automations.')
).toBe('Connect this ssh host before managing OpenClaw automations.')
})
it('explains disabled SSH automation actions while the host is connecting', () => {
@@ -58,11 +60,11 @@ describe('external automation action availability', () => {
getExternalAutomationActionDisabledMessage({
manager: manager({
target: { type: 'ssh', connectionId: 'devbox' },
error: 'Hermes CLI is not on the remote PATH.'
error: 'OpenClaw CLI is not on the remote PATH.'
}),
sshStatus: 'connected'
})
).toBe('Hermes CLI is not on the remote PATH.')
).toBe('OpenClaw CLI is not on the remote PATH.')
})
it('keeps concrete remote source errors when SSH status is unavailable to the caller', () => {
@@ -70,10 +72,10 @@ describe('external automation action availability', () => {
getExternalAutomationActionDisabledMessage({
manager: manager({
target: { type: 'ssh', connectionId: 'devbox' },
error: 'Hermes CLI is not on the remote PATH.'
error: 'OpenClaw CLI is not on the remote PATH.'
})
})
).toBe('Hermes CLI is not on the remote PATH.')
).toBe('OpenClaw CLI is not on the remote PATH.')
})
it('explains disabled actions while another automation action is running', () => {
@@ -1,7 +1,4 @@
import type {
ExternalAutomationManager,
ExternalAutomationProvider
} from '../../../../shared/automations-types'
import type { ExternalAutomationManager } from '../../../../shared/automations-types'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
@@ -22,7 +19,7 @@ export function getExternalAutomationActionDisabledMessage(args: {
if (args.manager.canManage) {
return null
}
const providerLabel = args.providerLabel ?? getProviderLabel(args.manager.provider)
const providerLabel = args.providerLabel ?? 'OpenClaw'
const targetKindLabel =
args.targetKindLabel ?? (args.manager.target.type === 'ssh' ? 'SSH host' : 'Local')
if (args.manager.target.type === 'ssh') {
@@ -46,10 +43,6 @@ export function getExternalAutomationActionDisabledMessage(args: {
)
}
function getProviderLabel(provider: ExternalAutomationProvider): string {
return provider === 'hermes' ? 'Hermes' : 'OpenClaw'
}
function isSshDisconnectedError(message: string): boolean {
return /ssh target is not connected/i.test(message)
}
@@ -1,68 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseHermesOutput } from './hermes-cron-output-parse'
afterEach(() => {
vi.restoreAllMocks()
})
describe('parseHermesOutput', () => {
it('parses title, metadata, and structured sections', () => {
const parsed = parseHermesOutput(
[
'# Cron Job: Nightly summary',
'**Schedule:** 0 9 * * *',
'**Run ID:** abc123',
'',
'## Response',
'Done',
'',
'## Prompt',
'Summarize work'
].join('\n')
)
expect(parsed.title).toBe('Nightly summary')
expect(parsed.metadata).toEqual([
{ label: 'Schedule', value: '0 9 * * *' },
{ label: 'Run ID', value: 'abc123' }
])
expect(parsed.sections).toEqual([
{ heading: 'Response', level: 2, body: 'Done' },
{ heading: 'Prompt', level: 2, body: 'Summarize work' }
])
})
it('normalizes CRLF section bodies without retaining carriage returns', () => {
const parsed = parseHermesOutput('# Cron Job: X\r\n\r\n## Response\r\none\r\ntwo\r\n')
expect(parsed.sections[0]?.body).toBe('one\ntwo')
})
it('parses newline-heavy structured output without splitting the full content', () => {
const split = vi.spyOn(String.prototype, 'split')
const body = Array.from({ length: 10_000 }, (_, index) => `line ${index + 1}`).join('\n')
const content = `# Cron Job: X\n\n## Response\n${body}\n`
const parsed = parseHermesOutput(content)
expect(parsed.sections[0]?.heading).toBe('Response')
expect(parsed.sections[0]?.body.startsWith('line 1\nline 2')).toBe(true)
expect(parsed.sections[0]?.body.endsWith('line 10000')).toBe(true)
expect(split).not.toHaveBeenCalled()
})
it('normalizes CRLF-heavy section bodies without a global replace pass', () => {
const replace = vi.spyOn(String.prototype, 'replace')
const body = Array.from({ length: 10_000 }, (_, index) => `line ${index + 1}`).join('\r\n')
const content = `# Cron Job: X\r\n\r\n## Response\r\n${body}\r\n`
const parsed = parseHermesOutput(content)
expect(parsed.sections[0]?.body.startsWith('line 1\nline 2')).toBe(true)
expect(parsed.sections[0]?.body.endsWith('line 10000')).toBe(true)
const usedCrlfReplace = replace.mock.calls.some(
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n'
)
expect(usedCrlfReplace).toBe(false)
})
})
@@ -1,162 +0,0 @@
export type ParsedHermesSection = {
heading: string
level: number
body: string
}
export type ParsedHermesOutput = {
title: string | null
metadata: { label: string; value: string }[]
sections: ParsedHermesSection[]
}
const METADATA_LINE_PATTERN = /^\*\*([^*]+):\*\*\s+(.+?)\s*$/
const LINE_FEED_CODE_UNIT = 10
const CARRIAGE_RETURN_CODE_UNIT = 13
type HermesLine = {
line: string
lineStart: number
nextLineStart: number
}
export function parseHermesOutput(content: string): ParsedHermesOutput {
let title: string | null = null
const metadata: { label: string; value: string }[] = []
let bodyStartOffset = 0
forEachHermesLine(content, 0, ({ line, nextLineStart }) => {
if (!title) {
const titleMatch = /^#\s+(?:Cron Job:\s*)?(.+?)\s*$/.exec(line)
if (titleMatch) {
title = titleMatch[1]
bodyStartOffset = nextLineStart
return true
}
}
const metaMatch = METADATA_LINE_PATTERN.exec(line)
if (metaMatch) {
metadata.push({ label: metaMatch[1].trim(), value: metaMatch[2].trim() })
bodyStartOffset = nextLineStart
return true
}
if (line.trim() === '') {
if (metadata.length > 0 || title) {
bodyStartOffset = nextLineStart
}
return true
}
return !(title || metadata.length > 0)
})
return {
title,
metadata,
sections: splitHermesSections(content, bodyStartOffset)
}
}
function splitHermesSections(content: string, startOffset: number): ParsedHermesSection[] {
const sections: ParsedHermesSection[] = []
let current: { heading: string; level: number; bodyStart: number; bodyEnd: number } | null = null
forEachHermesLine(content, startOffset, ({ line, lineStart, nextLineStart }) => {
const heading = /^(#{2,6})\s+(.+?)\s*$/.exec(line)
if (heading) {
if (current) {
sections.push(createHermesSection(content, current))
}
current = {
heading: heading[2],
level: heading[1].length,
bodyStart: nextLineStart,
bodyEnd: nextLineStart
}
return true
}
if (current) {
current.bodyEnd = nextLineStart
} else {
// Why: prose before the first structured heading was ignored by the
// previous parser; preserving that keeps fallback rendering unchanged.
void lineStart
}
return true
})
if (current) {
sections.push(createHermesSection(content, current))
}
return sections
}
function createHermesSection(
content: string,
section: { heading: string; level: number; bodyStart: number; bodyEnd: number }
): ParsedHermesSection {
return {
heading: section.heading,
level: section.level,
body: normalizeHermesSectionBody(content, section.bodyStart, section.bodyEnd)
}
}
const HERMES_TRIM_END_PATTERN = /\s/
function normalizeHermesSectionBody(content: string, start: number, end: number): string {
let trimEnd = Math.min(end, content.length)
while (trimEnd > start && HERMES_TRIM_END_PATTERN.test(content.charAt(trimEnd - 1))) {
trimEnd -= 1
}
let normalized = ''
let sliceStart = start
// Why: automation output can be newline-heavy; normalize section CRLF pairs
// while producing the section slice instead of running a full regex pass.
for (let index = start; index < trimEnd; index += 1) {
if (
content.charCodeAt(index) !== CARRIAGE_RETURN_CODE_UNIT ||
content.charCodeAt(index + 1) !== LINE_FEED_CODE_UNIT
) {
continue
}
normalized += `${content.slice(sliceStart, index)}\n`
index += 1
sliceStart = index + 1
}
if (sliceStart === start) {
return content.slice(start, trimEnd)
}
return normalized + content.slice(sliceStart, trimEnd)
}
function forEachHermesLine(
content: string,
startOffset: number,
visitor: (line: HermesLine) => boolean
): void {
let lineStart = startOffset
for (let index = startOffset; index <= content.length; index += 1) {
if (index < content.length && content.charCodeAt(index) !== LINE_FEED_CODE_UNIT) {
continue
}
const lineEnd =
index > lineStart && content.charCodeAt(index - 1) === CARRIAGE_RETURN_CODE_UNIT
? index - 1
: index
const shouldContinue = visitor({
line: content.slice(lineStart, lineEnd),
lineStart,
nextLineStart: index + 1
})
if (!shouldContinue) {
return
}
lineStart = index + 1
}
}
@@ -1,42 +1,34 @@
import { useCallback, useEffect } from 'react'
import type { AutomationTemplate } from './automation-templates'
import type { AutomationCreateTarget } from './AutomationEditorDialog'
import { getAutomationCreateRepos } from './automation-create-projects'
import { getDefaultWorktree } from './automation-draft-model'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageRefresh } from './use-automations-page-refresh'
import type { AutomationsPageSetupState } from './use-automations-page-setup-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Synchronizes draft defaults, setup policy, and template/target controls. */
/** Synchronizes draft defaults, setup policy, and templates. */
export function useAutomationDraftEffects({
store,
local,
setup,
destination,
destinationForm,
pageRefresh
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
setup: AutomationsPageSetupState
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
pageRefresh: AutomationsPageRefresh
}) {
const { repos, worktreesByRepo } = store
const {
draft,
setDraft,
createOpen,
createTarget,
editingAutomationId,
setupDecisionPolicyDefaultRef,
setupDecisionDefaultSignatureRef,
setupDecisionTouchedRef,
setCreateTarget
setupDecisionTouchedRef
} = local
const {
loadAutomationYamlHooksForRepo,
@@ -70,19 +62,13 @@ export function useAutomationDraftEffects({
}
}, [dialogWorktrees, draft.projectId, draft.workspaceId, setDraft])
useEffect(() => {
if (
!createOpen ||
createTarget !== 'orca' ||
draft.workspaceMode !== 'new_per_run' ||
!draft.projectId
) {
if (!createOpen || draft.workspaceMode !== 'new_per_run' || !draft.projectId) {
return
}
void loadAutomationYamlHooksForRepo(draft.projectId, createDestinationHostId)
}, [
createOpen,
createDestinationHostId,
createTarget,
draft.projectId,
draft.workspaceMode,
loadAutomationYamlHooksForRepo
@@ -139,41 +125,7 @@ export function useAutomationDraftEffects({
},
[setDraft]
)
const handleCreateTargetChange = useCallback(
(target: AutomationCreateTarget): void => {
setCreateTarget(target)
if (target !== 'hermes') {
return
}
const localRepos = getAutomationCreateRepos(repos, { kind: 'local' })
setDraft((current) => {
const currentRepo = repos.find((repo) => repo.id === current.projectId)
const currentRepoIsLocal =
currentRepo !== undefined &&
localRepos.some(
(repo) =>
repo.id === currentRepo.id &&
(repo.connectionId ?? null) === (currentRepo.connectionId ?? null)
)
const nextRepo = currentRepoIsLocal ? currentRepo : localRepos[0]
const nextWorkspace = nextRepo
? getDefaultWorktree(worktreesByRepo[nextRepo.id] ?? [])
: null
return {
...current,
agentId: 'hermes',
projectId: nextRepo?.id ?? '',
workspaceId: nextWorkspace?.id ?? '',
workspaceMode: 'existing',
setupDecision: undefined,
reuseSession: false
}
})
},
[repos, setCreateTarget, setDraft, worktreesByRepo]
)
return { applyTemplateToDraft, handleCreateTargetChange }
return { applyTemplateToDraft }
}
export type AutomationDraftEffects = ReturnType<typeof useAutomationDraftEffects>
@@ -1,18 +1,11 @@
import { useCallback } from 'react'
import type {
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
import { getAutomationCreateRepos } from './automation-create-projects'
import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft'
import { buildAutomationEditDraft } from './automation-edit-draft'
import { AUTOMATION_DEFAULT_TIME, getDefaultWorktree } from './automation-draft-model'
import type { AutomationTemplate } from './automation-templates'
import { dispatchAutomationReread } from './automation-row-action-dispatch'
import { listAutomationsForTarget } from './automation-host-client'
import { repoMatchesExternalAutomationTarget } from './automation-external-target-match'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import type { AutomationDraft } from './AutomationEditorDialog'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
@@ -32,15 +25,13 @@ export function useAutomationEditorActions({
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
}) {
const { defaultAgent, worktreesByRepo, repoMap, fetchWorktrees, repos } = store
const { defaultAgent, worktreesByRepo, fetchWorktrees } = store
const {
editRequestRef,
setEditingAutomationId,
setEditingRowKey,
setEditingDestination,
setEditingHostStableKey,
setEditingExternalTarget,
setCreateTarget,
setDraft,
setDraftAtOpen,
setCreateOpen,
@@ -55,10 +46,8 @@ export function useAutomationEditorActions({
editRequestRef.current += 1
const target = getDefaultTarget()
setEditingAutomationId(null)
setEditingExternalTarget(null)
setEditingDestination(null)
setEditingHostStableKey(null)
setCreateTarget('orca')
const baseDraft: AutomationDraft = {
name: '',
prompt: '',
@@ -99,8 +88,6 @@ export function useAutomationEditorActions({
const openEditDialog = async (row: AutomationListRow): Promise<void> => {
const requestId = (editRequestRef.current += 1)
setEditingExternalTarget(null)
setCreateTarget('orca')
const automationId = row.automation.id
const reread = await dispatchAutomationReread(
automationDispatchContext,
@@ -132,42 +119,6 @@ export function useAutomationEditorActions({
setCreateOpen(true)
}
const openEditExternalDialog = (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
): void => {
editRequestRef.current += 1
const targetWorktree = Object.values(worktreesByRepo)
.flat()
.find((worktree) => {
const repo = repoMap.get(worktree.repoId)
return (
repo !== undefined &&
repoMatchesExternalAutomationTarget(repo, manager.target) &&
job.workdir !== null &&
worktree.path === job.workdir
)
})
const localRepos = getAutomationCreateRepos(repos, { kind: 'local' })
const fallbackRepo = localRepos[0] ?? null
const fallbackWorktree = fallbackRepo
? getDefaultWorktree(worktreesByRepo[fallbackRepo.id] ?? [])
: null
const projectId = targetWorktree?.repoId ?? fallbackRepo?.id ?? ''
const workspaceId = targetWorktree?.id ?? fallbackWorktree?.id ?? ''
const nextDraft = buildExternalAutomationEditDraft(job, { projectId, workspaceId })
setEditingAutomationId(null)
setEditingRowKey(null)
setEditingDestination(null)
setEditingHostStableKey(null)
setEditingExternalTarget({ manager, job, scope })
setCreateTarget('hermes')
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
}
const handleProjectChange = useCallback(
(projectId: string): void => {
const currentWorktrees = worktreesByRepo[projectId] ?? []
@@ -256,7 +207,6 @@ export function useAutomationEditorActions({
return {
openCreateDialog,
openEditDialog,
openEditExternalDialog,
handleProjectChange,
handleDraftChange
}
@@ -48,7 +48,6 @@ export function useAutomationRunPageState({
rerunRunIdsInFlight,
selectedExternalKey,
selectExternalKey,
setSelectedExternalRunPage,
pageView,
setPageView,
isDetailOpen,
@@ -72,7 +71,6 @@ export function useAutomationRunPageState({
}
setIsDetailOpen(false)
setSelectedAutomationRunPageId(null)
setSelectedExternalRunPage(null)
setActivePaneTab('overview')
}, [
isDetailOpen,
@@ -84,8 +82,7 @@ export function useAutomationRunPageState({
selectedRow,
setActivePaneTab,
setIsDetailOpen,
setSelectedAutomationRunPageId,
setSelectedExternalRunPage
setSelectedAutomationRunPageId
])
useEffect(() => {
if (!pendingAutomationRunNavigation || isLoading) {
@@ -55,7 +55,6 @@ export function useAutomationsPageController() {
destination
})
const draftEffects = useAutomationDraftEffects({
store,
local,
setup,
destination,
@@ -52,7 +52,6 @@ export function useAutomationsPageDestinationForm({
} = store
const {
createOpen,
createTarget,
editingAutomationId,
editingRowKey,
editingHostStableKey,
@@ -83,7 +82,6 @@ export function useAutomationsPageDestinationForm({
getAutomationTargetFromHostId(editingRow?.automation.runContext?.hostId)
)
})()
const isOrcaForm = createTarget === 'orca' && local.editingExternalTarget === null
const dialogAuthorityRepos = getAutomationCreateRepos(repos, automationDialogTarget)
const dialogAuthorityKey = automationAuthorityCatalogKey(
automationDialogTarget.kind === 'environment'
@@ -159,16 +157,12 @@ export function useAutomationsPageDestinationForm({
).replace('{host}', () => editMoveTargetEntry.authorityLabel)
: null
}
const dialogRepos = isOrcaForm
? editingAutomationId !== null
? editHostProjects
: editorProjects
: getAutomationCreateRepos(repos, { kind: 'local' })
const dialogRepos = editingAutomationId !== null ? editHostProjects : editorProjects
// A destination change can strand the chosen project on another host; clear
// it so the draft/default-target effect can select a project the host owns.
useEffect(() => {
if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') {
if (!createOpen || editingAutomationId !== null) {
return
}
setDraft((current) =>
@@ -176,7 +170,7 @@ export function useAutomationsPageDestinationForm({
? current
: { ...current, projectId: '', workspaceId: '', baseBranch: '' }
)
}, [createOpen, createTarget, editingAutomationId, editorProjects, setDraft])
}, [createOpen, editingAutomationId, editorProjects, setDraft])
const dialogWorktrees = useMemo(() => {
const candidates = worktreesByRepo[draft.projectId] ?? []
@@ -230,7 +224,6 @@ export function useAutomationsPageDestinationForm({
editingRow,
editingRowCapturedOwner,
automationDialogTarget,
isOrcaForm,
dialogAuthorityRepos,
dialogAuthorityKey,
editHostEntries,
@@ -57,7 +57,7 @@ export function useAutomationsPageDestinationState({
runtimeEnvironments,
fetchRuntimeEnvironmentRepos
} = store
const { createOpen, createTarget, editingAutomationId, editingRowKey, setOwnerAction } = local
const { createOpen, editingAutomationId, editingRowKey, setOwnerAction } = local
const { hostCatalog, capturedAutomationOwners } = list
const automationHostTarget = useMemo(
@@ -86,7 +86,7 @@ export function useAutomationsPageDestinationState({
: null
}, [activeWorktreeId, repoMap, worktreeMap])
const createDestination = useAutomationCreateDestination({
open: createOpen && editingAutomationId === null && createTarget === 'orca',
open: createOpen && editingAutomationId === null,
catalog: hostCatalog.catalog,
entries: hostCatalog.entries,
filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null,
@@ -17,14 +17,12 @@ export function useAutomationsPageEscape({
externalDeleteTarget,
isDetailOpen,
selectedAutomationRunPageId,
selectedExternalRunPage,
pageView,
runPageOrigin,
setPageView,
setActivePaneTab,
setIsDetailOpen,
setSelectedAutomationRunPageId,
setSelectedExternalRunPage
setSelectedAutomationRunPageId
} = local
useEffect(() => {
if (createOpen || deleteTarget || externalDeleteTarget || activeModal !== 'none') {
@@ -75,10 +73,6 @@ export function useAutomationsPageEscape({
if (isDetailOpen) {
event.preventDefault()
if (selectedExternalRunPage) {
setSelectedExternalRunPage(null)
return
}
if (selectedAutomationRunPageId) {
setSelectedAutomationRunPageId(null)
return
@@ -110,11 +104,9 @@ export function useAutomationsPageEscape({
pageView,
runPageOrigin,
selectedAutomationRunPageId,
selectedExternalRunPage,
setActivePaneTab,
setIsDetailOpen,
setSelectedAutomationRunPageId,
setSelectedExternalRunPage,
setPageView
])
}
@@ -46,7 +46,6 @@ export function useAutomationsPageListState({
[hostCatalog.entries, hostCatalog.resolution]
)
const scopedExternal = useScopedExternalAutomations({
catalogEntries: hostCatalog.entries,
scopeEntries: externalScopeEntries
})
const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations])
@@ -6,7 +6,7 @@ import type {
} from '../../../../shared/automations-types'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog'
import type { AutomationDraft } from './AutomationEditorDialog'
import { AUTOMATION_DEFAULT_TIME } from './automation-draft-model'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
@@ -20,8 +20,7 @@ import {
import type {
AutomationPaneTab,
AutomationRunPageOrigin,
AutomationsPageView,
SelectedExternalRunPage
AutomationsPageView
} from './automation-page-state'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import type { SelectedAutomationRunHistoryOutcome } from './use-selected-automation-run-history'
@@ -60,7 +59,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
const [listFilter, setListFilter] = useState<AutomationListFilter>(EMPTY_AUTOMATION_LIST_FILTER)
const [listSort, setListSort] = useState<AutomationListSort | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createTarget, setCreateTarget] = useState<AutomationCreateTarget>('orca')
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
const [editingRowKey, setEditingRowKey] = useState<string | null>(null)
const [editingDestination, setEditingDestination] = useState<{
@@ -78,8 +76,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
)
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null)
const [selectedExternalKey, setSelectedExternalKey] = useState<string | null>(null)
const [selectedExternalRunPage, setSelectedExternalRunPage] =
useState<SelectedExternalRunPage | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const selectedExternalKeyRef = useRef<string | null>(null)
useEffect(() => {
@@ -95,7 +91,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
[setSelectedId]
)
const selectExternalKey = useCallback((externalKey: string | null): void => {
setSelectedExternalRunPage(null)
setSelectedExternalKey(externalKey)
}, [])
const [draftAtOpen, setDraftAtOpen] = useState<AutomationDraft | null>(null)
@@ -110,11 +105,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
!isLoading && !createOpen && !deleteTarget && !externalDeleteTarget,
'automations_open'
)
const [editingExternalTarget, setEditingExternalTarget] = useState<{
manager: ExternalAutomationManager
job: ExternalAutomationJob
scope: ExternalAutomationScope
} | null>(null)
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
const editRequestRef = useRef(0)
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
@@ -189,8 +179,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
setListSort,
createOpen,
setCreateOpen,
createTarget,
setCreateTarget,
editingAutomationId,
setEditingAutomationId,
editingRowKey,
@@ -214,8 +202,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
setSelectedRowKey,
selectedExternalKey,
setSelectedExternalKey,
selectedExternalRunPage,
setSelectedExternalRunPage,
isDetailOpen,
setIsDetailOpen,
selectedExternalKeyRef,
@@ -227,8 +213,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
setDeleteTarget,
externalDeleteTarget,
setExternalDeleteTarget,
editingExternalTarget,
setEditingExternalTarget,
dontAskDeleteAgain,
setDontAskDeleteAgain,
editRequestRef,
@@ -28,7 +28,6 @@ export function useAutomationsPageSetupState({
}) {
const { repos, settings, projectHostSetups, worktreesByRepo } = store
const {
createTarget,
automationYamlHooksByRepoKey,
automationHookCheckPromisesRef,
setAutomationYamlHooksByRepoKey,
@@ -74,7 +73,6 @@ export function useAutomationsPageSetupState({
: projectHostSetups
const hookKey = `${setupHostId ?? getRuntimeEnvironmentIdForRepo({ repos, settings }, candidate.projectId) ?? 'local'}:${candidate.projectId}`
return getVisibleAutomationSetupDecision({
createTarget,
workspaceMode: candidate.workspaceMode,
repoId: candidate.projectId,
repos: setupRepos,
@@ -84,7 +82,6 @@ export function useAutomationsPageSetupState({
},
[
automationYamlHooksByRepoKey,
createTarget,
list.hostCatalog.entries,
local.editingAutomationId,
local.editingHostStableKey,
@@ -148,12 +145,11 @@ export function useAutomationsPageSetupState({
const getDraftSetupDecisionDefaultSignature = useCallback(
(candidate: Pick<AutomationDraft, 'projectId' | 'workspaceMode'>): string =>
[
createTarget,
candidate.workspaceMode,
candidate.projectId,
getDraftSetupDecisionDefault(candidate) ?? 'none'
].join(':'),
[createTarget, getDraftSetupDecisionDefault]
[getDraftSetupDecisionDefault]
)
const markSetupDecisionTouched = useCallback((): void => {
setupDecisionTouchedRef.current = true
@@ -1,16 +1,12 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import type {
AutomationRun,
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun
ExternalAutomationManager
} from '../../../../shared/automations-types'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
import { isMissingExternalRunsApiError } from './external-automation-display'
import {
externalAutomationActionKey,
externalAutomationJobKey
@@ -26,7 +22,6 @@ export function useExternalAutomationActions({
}: AutomationsPageActionContext) {
const {
setExternalActionKey,
setSelectedExternalRunPage,
setSelectedAutomationRunPageId,
setExternalDeleteTarget,
externalDeleteTarget,
@@ -36,7 +31,6 @@ export function useExternalAutomationActions({
setActivePaneTab
} = local
const { scopedExternal } = list
const fetchScopedExternalRuns = scopedExternal.fetchRuns
const runExternalAction = async (
scope: ExternalAutomationScope,
@@ -94,30 +88,6 @@ export function useExternalAutomationActions({
}
}
const fetchExternalAutomationRuns = useCallback<FetchExternalAutomationRuns>(
async ({ scope, job, page, pageSize }) => {
try {
const result = await fetchScopedExternalRuns(scope, job, page, pageSize)
return { runs: [...result.runs], totalCount: result.totalCount }
} catch (error) {
if (isMissingExternalRunsApiError(error)) {
return {
runs: job.runs.slice(page * pageSize, page * pageSize + pageSize),
totalCount: job.runCount
}
}
throw error
}
},
[fetchScopedExternalRuns]
)
const openExternalRunPage = (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
run: ExternalAutomationRun
): void => {
setSelectedExternalRunPage({ manager, job, run })
}
const openAutomationRunPage = (run: AutomationRun): void => {
setSelectedAutomationRunPageId(run.id)
}
@@ -144,8 +114,6 @@ export function useExternalAutomationActions({
return {
runExternalAction,
fetchExternalAutomationRuns,
openExternalRunPage,
openAutomationRunPage,
requestExternalAction,
confirmDeleteExternalAutomation
@@ -1,18 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
ExternalAutomationAction,
ExternalAutomationJob
} from '../../../../shared/automations-types'
import type { ScopedExternalManagerMutationFields } from '../../../../shared/external-automation-scope'
import type { ExternalAutomationAction } from '../../../../shared/automations-types'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import {
createScopedExternalAutomation,
desktopExternalAutomationOwner,
externalAutomationScopes,
listScopedExternalAutomationManagers,
listScopedExternalAutomationRuns,
runScopedExternalAutomationAction,
updateScopedExternalAutomation,
type ExternalAutomationScope,
type ScopedExternalAutomationFailure,
type ScopedExternalAutomationManager
@@ -24,15 +16,13 @@ import { useExternalAutomationScopeRetention } from './use-external-automation-s
* External managers for the hosts currently in view, and the scope each one was
* discovered under.
*
* A manager ID is unique only inside one host — `hermes:local` names no
* A manager ID is unique only inside one host — `openclaw:local` names no
* authority at all — so every call here takes the scope its row was discovered
* under rather than a manager to look one up from. There is no lookup to miss
* and no host to guess at.
*/
export type ScopedExternalAutomationsInput = {
/** The whole catalog — used to resolve a create target, never to probe. */
catalogEntries: readonly AutomationHostCatalogEntry[]
/** The hosts in view. Only these are probed, and only these are retained. */
scopeEntries: readonly AutomationHostCatalogEntry[]
}
@@ -43,42 +33,22 @@ export type ScopedExternalAutomationsView = {
failures: readonly ScopedExternalAutomationFailure[]
/** Reloads every in-view scope; safe to call from the page's refresh path. */
reload: () => Promise<void>
/** The scope a new external automation would be created in, or null to disable it. */
createScope: (connectionId: string | null) => ExternalAutomationScope | null
saveExternalAutomation: (
scope: ExternalAutomationScope,
fields: ScopedExternalManagerMutationFields,
jobId: string | null
) => Promise<void>
runExternalAction: (
scope: ExternalAutomationScope,
jobId: string,
action: ExternalAutomationAction
) => Promise<void>
fetchRuns: (
scope: ExternalAutomationScope,
job: ExternalAutomationJob,
page: number,
pageSize: number
) => Promise<{ runs: readonly ExternalAutomationRunRow[]; totalCount: number }>
}
type ExternalAutomationRunRow = Awaited<
ReturnType<typeof listScopedExternalAutomationRuns>
>['runs'][number]
const EMPTY_VIEW = {
managers: [] as readonly ScopedExternalAutomationManager[],
failures: [] as readonly ScopedExternalAutomationFailure[]
}
export function useScopedExternalAutomations({
catalogEntries,
scopeEntries
}: ScopedExternalAutomationsInput): ScopedExternalAutomationsView {
const [result, setResult] = useState(EMPTY_VIEW)
const catalogEntriesRef = useRef(catalogEntries)
catalogEntriesRef.current = catalogEntries
useExternalAutomationScopeRetention(scopeEntries)
const scopes = useMemo(() => externalAutomationScopes(scopeEntries), [scopeEntries])
@@ -117,33 +87,13 @@ export function useScopedExternalAutomations({
void reload().catch(() => undefined)
}, [signature, reload])
const createScope = useCallback((connectionId: string | null): ExternalAutomationScope | null => {
const owner = desktopExternalAutomationOwner(catalogEntriesRef.current, connectionId)
// Hermes is the only provider a create dialog offers today; the picker
// that would choose between providers does not exist yet.
return owner ? { owner, provider: 'hermes' } : null
}, [])
return {
managers: result.managers,
failures: result.failures,
reload,
createScope,
saveExternalAutomation: useCallback(
async (scope, fields, jobId) =>
jobId === null
? await createScopedExternalAutomation(scope, fields)
: await updateScopedExternalAutomation(scope, jobId, fields),
[]
),
runExternalAction: useCallback(
async (scope, jobId, action) => await runScopedExternalAutomationAction(scope, jobId, action),
[]
),
fetchRuns: useCallback(
async (scope, job, page, pageSize) =>
await listScopedExternalAutomationRuns(scope, job, page, pageSize),
[]
)
}
}
+1 -36
View File
@@ -248,7 +248,7 @@ export type AutomationDispatchResult = {
error?: string | null
}
export type ExternalAutomationProvider = 'hermes' | 'openclaw'
export type ExternalAutomationProvider = 'openclaw'
export type ExternalAutomationManagerStatus = 'available' | 'unavailable'
export type ExternalAutomationAction = 'pause' | 'resume' | 'run' | 'delete'
export type ExternalAutomationRunStatus = 'completed' | 'failed' | 'unknown'
@@ -296,41 +296,6 @@ export type ExternalAutomationRun = {
outputPath: string | null
}
export type ExternalAutomationRunsPage = {
managerId: string
provider: ExternalAutomationProvider
target: ExternalAutomationTarget
jobId: string
page: number
pageSize: number
total: number
totalSaturated?: true
runs: ExternalAutomationRun[]
}
export type ExternalAutomationRunsInput = {
managerId: string
provider: ExternalAutomationProvider
target: ExternalAutomationTarget
jobId: string
page: number
pageSize: number
}
export type ExternalAutomationCreateInput = {
managerId: string
provider: ExternalAutomationProvider
target: ExternalAutomationTarget
name: string
prompt: string
schedule: string
workdir: string | null
}
export type ExternalAutomationUpdateInput = ExternalAutomationCreateInput & {
jobId: string
}
export type ExternalAutomationManager = {
id: string
provider: ExternalAutomationProvider
+2 -27
View File
@@ -23,7 +23,6 @@ import type {
} from './automations-types'
export const EXTERNAL_AUTOMATION_PROVIDERS = [
'hermes',
'openclaw'
] as const satisfies readonly ExternalAutomationProvider[]
@@ -40,9 +39,7 @@ export const EXTERNAL_AUTOMATION_SCOPE_CODES = {
/** Only the desktop authority owns external managers; runtime authorities must not be tunnelled. */
authorityNotSupported: 'external_automation_authority_not_supported',
/** The SSH target is an Orca implementation detail and stays out of user-facing surfaces. */
targetHidden: 'external_automation_target_hidden',
/** The relay answered `-32601`: it is healthy but has no external-runs method. */
runsUnsupported: 'external_automation_runs_unsupported'
targetHidden: 'external_automation_target_hidden'
} as const
export type ExternalAutomationScopeCode =
@@ -54,9 +51,7 @@ const SCOPE_MESSAGES: Record<ExternalAutomationScopeCode, string> = {
[EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported]:
'External automation managers are available only on this computer and its SSH hosts.',
[EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden]:
'That host is managed by Orca and does not expose external automations.',
[EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported]:
'This host does not report external automation run history.'
'That host is managed by Orca and does not expose external automations.'
}
/**
@@ -94,26 +89,6 @@ export type ScopedExternalManagerListRequest = ScopedExternalAutomationRequest &
refresh?: boolean
}
export type ScopedExternalManagerRunsRequest = ScopedExternalAutomationRequest & {
jobId: string
page: number
pageSize: number
}
export type ScopedExternalManagerMutationFields = {
name: string
prompt: string
schedule: string
workdir: string | null
}
export type ScopedExternalManagerCreateRequest = ScopedExternalAutomationRequest &
ScopedExternalManagerMutationFields
export type ScopedExternalManagerUpdateRequest = ScopedExternalManagerCreateRequest & {
jobId: string
}
export type ScopedExternalManagerActionRequest = ScopedExternalAutomationRequest & {
jobId: string
action: ExternalAutomationAction
-69
View File
@@ -1,69 +0,0 @@
export const HERMES_RUN_REF_MAX_ENTRIES = 10_000
export type HermesSortableRunRef = {
id: string
run_at: string | null
}
export type BoundedHermesRunRefs<T> = {
refs: T[]
saturated: boolean
}
export class HermesRunRefRetainer<T extends HermesSortableRunRef> {
private readonly retained: T[] = []
private readonly pending: T[] = []
private readonly maxEntries: number
private seen = 0
constructor(maxEntries = HERMES_RUN_REF_MAX_ENTRIES) {
this.maxEntries = Number.isFinite(maxEntries)
? Math.max(0, Math.min(HERMES_RUN_REF_MAX_ENTRIES, Math.floor(maxEntries)))
: HERMES_RUN_REF_MAX_ENTRIES
}
add(ref: T): void {
this.seen += 1
if (this.maxEntries === 0) {
return
}
this.pending.push(ref)
if (this.pending.length >= this.maxEntries) {
this.flush()
}
}
finish(): BoundedHermesRunRefs<T> {
this.flush()
return {
refs: this.retained.slice(),
saturated: this.seen > this.maxEntries
}
}
private flush(): void {
if (this.pending.length === 0) {
return
}
for (const ref of this.pending) {
this.retained.push(ref)
}
this.pending.length = 0
this.retained.sort(compareHermesRunRefsNewestFirst)
if (this.retained.length > this.maxEntries) {
this.retained.length = this.maxEntries
}
}
}
export function compareHermesRunRefsNewestFirst(
left: HermesSortableRunRef,
right: HermesSortableRunRef
): number {
const leftTime = left.run_at ? Date.parse(left.run_at) : Number.NaN
const rightTime = right.run_at ? Date.parse(right.run_at) : Number.NaN
if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) {
return rightTime - leftTime
}
return right.id.localeCompare(left.id)
}
@@ -1,72 +0,0 @@
import { expect, it, vi } from 'vitest'
import { HermesSessionRunIndex } from './hermes-session-run-index'
const time = (key: string | null): number =>
key === null || key === 'invalid' ? Number.NaN : Number(key)
function legacyMatch(
keys: (string | null)[],
used: Set<number>,
key: string | null
): number | null {
const exact = keys.findIndex((candidate, index) => !used.has(index) && candidate === key)
if (exact !== -1) {
return exact
}
const outputTime = time(key)
if (!Number.isFinite(outputTime)) {
return null
}
let best: number | null = null
let bestGap = Infinity
keys.forEach((candidate, index) => {
const gap = outputTime - time(candidate)
if (!used.has(index) && Number.isFinite(gap) && gap >= 0 && gap <= 24 && gap < bestGap) {
best = index
bestGap = gap
}
})
return best
}
it('preserves exact, null, invalid, duplicate-time, source-order, and maximum-gap matching', () => {
let seed = 70291
const random = (max: number): number => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return seed % max
}
const pool = [null, 'invalid', '-1', '0', '00', '1', '2', '24', '25', '26', '50']
for (let trial = 0; trial < 500; trial++) {
const keys = Array.from({ length: random(60) }, () => pool[random(pool.length)])
const index = new HermesSessionRunIndex(keys, time, 24)
const used = new Set<number>()
for (let query = 0; query < 80; query++) {
const key = pool[random(pool.length)]
const expected = legacyMatch(keys, used, key)
expect(index.find(key)).toBe(expected)
// Invalid source rows can decline a match; find must leave it available.
if (expected !== null && random(4) !== 0) {
used.add(expected)
index.use(expected)
}
}
expect(index.used).toEqual(used)
}
})
it('parses each session timestamp once across a long run history', () => {
const parse = vi.fn(time)
const count = 5000
const index = new HermesSessionRunIndex(
Array.from({ length: count }, (_, i) => String(i * 60)),
parse,
24
)
for (let i = count - 1; i >= 0; i--) {
const match = index.find(String(i * 60 + 2))
expect(match).toBe(i)
index.use(match!)
}
expect(parse).toHaveBeenCalledTimes(count * 2)
expect(index.find('9999999')).toBeNull()
})
-85
View File
@@ -1,85 +0,0 @@
/** Exact keys win; otherwise select the nearest unused earlier session, with source-order ties. */
export class HermesSessionRunIndex {
readonly used = new Set<number>()
private readonly exact = new Map<string | null, { indices: number[]; cursor: number }>()
private readonly timed: { time: number; index: number }[] = []
private readonly positionByIndex = new Map<number, number>()
private readonly predecessors: number[]
constructor(
keys: (string | null)[],
private readonly parseTime: (key: string | null) => number,
private readonly maxGapMs: number
) {
keys.forEach((key, index) => {
let group = this.exact.get(key)
if (!group) {
group = { indices: [], cursor: 0 }
this.exact.set(key, group)
}
group.indices.push(index)
const time = parseTime(key)
if (Number.isFinite(time)) {
this.timed.push({ time, index })
}
})
// The rightmost equal-time row must be the first row in source order.
this.timed.sort((a, b) => a.time - b.time || b.index - a.index)
this.timed.forEach((row, position) => this.positionByIndex.set(row.index, position + 1))
// Zero is the sentinel before the first row; consumed rows link to their predecessor.
this.predecessors = Array.from({ length: this.timed.length + 1 }, (_, position) => position)
}
find(key: string | null): number | null {
const group = this.exact.get(key)
if (group) {
while (group.cursor < group.indices.length && this.used.has(group.indices[group.cursor])) {
group.cursor++
}
if (group.cursor < group.indices.length) {
return group.indices[group.cursor]
}
}
const time = this.parseTime(key)
if (!Number.isFinite(time)) {
return null
}
let low = 0
let high = this.timed.length
while (low < high) {
const mid = low + Math.floor((high - low) / 2)
if (this.timed[mid].time <= time) {
low = mid + 1
} else {
high = mid
}
}
const position = this.findPredecessor(low)
if (position === 0) {
return null
}
const candidate = this.timed[position - 1]
return time - candidate.time <= this.maxGapMs ? candidate.index : null
}
use(index: number): void {
this.used.add(index)
const position = this.positionByIndex.get(index)
if (position !== undefined) {
this.predecessors[position] = this.findPredecessor(position - 1)
}
}
private findPredecessor(position: number): number {
let root = position
while (this.predecessors[root] !== root) {
root = this.predecessors[root]
}
while (this.predecessors[position] !== position) {
const next = this.predecessors[position]
this.predecessors[position] = root
position = next
}
return root
}
}