From 8644ade7fc2166ed186e9ef274e8e0e03bcadceb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:36:43 -0700 Subject: [PATCH] Remove Hermes automation support (#28519) * Remove external cron history from the automation UI * Simplify the automation editor * Remove Hermes cron discovery and management --- .../hermes-run-correlation-benchmark.mjs | 133 ------- .../external-automation-manager-cache.test.ts | 53 ++- .../external-automation-owner-guard.test.ts | 18 +- ...xternal-automation-probe-scheduler.test.ts | 16 +- src/main/automations/external-job-mappers.ts | 79 ---- .../automations/external-manager-discovery.ts | 49 +-- .../external-manager-local-command.ts | 2 +- .../automations/external-manager-mutations.ts | 79 +--- src/main/automations/external-manager-runs.ts | 39 -- .../external-manager-scoped.test.ts | 101 +++-- src/main/automations/external-manager.test.ts | 257 +------------ src/main/automations/external-manager.ts | 61 +-- .../headless-automation-agent-launch.test.ts | 14 + .../hermes-cron-output-markdown.ts | 284 -------------- .../automations/hermes-cron-output.test.ts | 352 ------------------ src/main/automations/hermes-cron-output.ts | 290 --------------- .../automations/hermes-cron-run-content.ts | 234 ------------ .../hermes-cron-run-count-cache.ts | 64 ---- .../ipc/automations-external-scope.test.ts | 91 +++-- src/main/ipc/automations.ts | 21 +- src/preload/api/automation-api.ts | 13 +- src/preload/api/automations-bridge.ts | 14 +- .../automations/AutomationEditorDialog.tsx | 22 +- .../AutomationEditorDialogFooter.tsx | 15 +- .../AutomationEditorDialogHeader.tsx | 57 +-- .../AutomationEditorSettingsSidebar.test.tsx | 2 - .../AutomationEditorSettingsSidebar.tsx | 139 ++----- .../automations/AutomationListExternalRow.tsx | 28 +- .../AutomationListLocalRows.test.tsx | 9 +- .../AutomationSetupDecisionField.tsx | 5 +- .../automations/AutomationWorkspaceField.tsx | 109 +++--- .../AutomationsDetailPane.run-count.test.tsx | 5 - .../AutomationsDetailPane.test.tsx | 10 - .../automations/AutomationsDetailPane.tsx | 95 +---- .../automations/AutomationsListPanel.test.tsx | 1 - .../automations/AutomationsListPanel.tsx | 7 - .../AutomationsPage.external-scope.test.tsx | 22 +- .../automations/AutomationsPage.test.tsx | 123 ++---- .../automations/AutomationsPageListPanel.tsx | 1 - .../automations/AutomationsPageSurface.tsx | 22 +- .../ExternalAutomationManagers.test.tsx | 43 +-- .../ExternalAutomationManagers.tsx | 83 +---- .../ExternalAutomationRunTable.tsx | 312 ---------------- .../automations/HermesCronOutputView.tsx | 286 -------------- .../automations/automation-draft-model.ts | 14 - .../automations/automation-edit-draft.ts | 42 +-- .../automation-external-target-match.test.ts | 50 --- .../automation-external-target-match.ts | 20 - .../automations/automation-hermes-save.ts | 89 ----- .../automation-list-last-run.test.ts | 2 +- .../automation-list-search-rows.test.ts | 4 +- .../automations/automation-list-view.test.ts | 6 +- .../automations/automation-orca-save.ts | 2 - .../automations/automation-page-state.ts | 13 - .../automations/automation-save-action.ts | 56 +-- .../automation-setup-decision.test.ts | 17 - .../automations/automation-setup-decision.ts | 6 +- .../automations/automations-page-fixtures.ts | 14 +- .../automations-page-test-harness.tsx | 15 - .../external-automation-display.test.ts | 33 -- .../external-automation-display.ts | 51 +-- .../external-automation-list-entries.test.ts | 18 +- ...xternal-automation-run-table-state.test.ts | 108 ------ .../external-automation-run-table-state.ts | 75 ---- .../external-automation-scope-client.ts | 71 +--- .../external-automation-scope-keys.test.ts | 32 +- .../external-automation-scope-keys.ts | 20 +- ...nal-automation-source-availability.test.ts | 24 +- ...external-automation-source-availability.ts | 11 +- .../hermes-cron-output-parse.test.ts | 68 ---- .../automations/hermes-cron-output-parse.ts | 162 -------- .../use-automation-draft-effects.ts | 56 +-- .../use-automation-editor-actions.ts | 54 +-- .../use-automation-run-page-state.ts | 5 +- .../use-automations-page-controller.ts | 1 - .../use-automations-page-destination-form.ts | 13 +- .../use-automations-page-destination-state.ts | 4 +- .../use-automations-page-escape.ts | 10 +- .../use-automations-page-list-state.ts | 1 - .../use-automations-page-local-state.ts | 20 +- .../use-automations-page-setup-state.ts | 6 +- .../use-external-automation-actions.ts | 34 +- .../use-scoped-external-automations.ts | 54 +-- src/shared/automations-types.ts | 37 +- src/shared/external-automation-scope.ts | 29 +- src/shared/hermes-run-ref-retention.ts | 69 ---- src/shared/hermes-session-run-index.test.ts | 72 ---- src/shared/hermes-session-run-index.ts | 85 ----- 88 files changed, 505 insertions(+), 4728 deletions(-) delete mode 100644 config/scripts/hermes-run-correlation-benchmark.mjs delete mode 100644 src/main/automations/external-manager-runs.ts delete mode 100644 src/main/automations/hermes-cron-output-markdown.ts delete mode 100644 src/main/automations/hermes-cron-output.test.ts delete mode 100644 src/main/automations/hermes-cron-output.ts delete mode 100644 src/main/automations/hermes-cron-run-content.ts delete mode 100644 src/main/automations/hermes-cron-run-count-cache.ts delete mode 100644 src/renderer/src/components/automations/ExternalAutomationRunTable.tsx delete mode 100644 src/renderer/src/components/automations/HermesCronOutputView.tsx delete mode 100644 src/renderer/src/components/automations/automation-external-target-match.test.ts delete mode 100644 src/renderer/src/components/automations/automation-external-target-match.ts delete mode 100644 src/renderer/src/components/automations/automation-hermes-save.ts delete mode 100644 src/renderer/src/components/automations/external-automation-display.test.ts delete mode 100644 src/renderer/src/components/automations/external-automation-run-table-state.test.ts delete mode 100644 src/renderer/src/components/automations/external-automation-run-table-state.ts delete mode 100644 src/renderer/src/components/automations/hermes-cron-output-parse.test.ts delete mode 100644 src/renderer/src/components/automations/hermes-cron-output-parse.ts delete mode 100644 src/shared/hermes-run-ref-retention.ts delete mode 100644 src/shared/hermes-session-run-index.test.ts delete mode 100644 src/shared/hermes-session-run-index.ts diff --git a/config/scripts/hermes-run-correlation-benchmark.mjs b/config/scripts/hermes-run-correlation-benchmark.mjs deleted file mode 100644 index f392136b5cf..00000000000 --- a/config/scripts/hermes-run-correlation-benchmark.mjs +++ /dev/null @@ -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 }) -} diff --git a/src/main/automations/external-automation-manager-cache.test.ts b/src/main/automations/external-automation-manager-cache.test.ts index 9c7b7d97be5..a86a4ebb0b0 100644 --- a/src/main/automations/external-automation-manager-cache.test.ts +++ b/src/main/automations/external-automation-manager-cache.test.ts @@ -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') }) }) diff --git a/src/main/automations/external-automation-owner-guard.test.ts b/src/main/automations/external-automation-owner-guard.test.ts index f018f8b0b22..50e8e1d9ba9 100644 --- a/src/main/automations/external-automation-owner-guard.test.ts +++ b/src/main/automations/external-automation-owner-guard.test.ts @@ -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( diff --git a/src/main/automations/external-automation-probe-scheduler.test.ts b/src/main/automations/external-automation-probe-scheduler.test.ts index ce3d9dc1124..21c8d29e610 100644 --- a/src/main/automations/external-automation-probe-scheduler.test.ts +++ b/src/main/automations/external-automation-probe-scheduler.test.ts @@ -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() 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() 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) diff --git a/src/main/automations/external-job-mappers.ts b/src/main/automations/external-job-mappers.ts index 60677dda99c..b0c699cf221 100644 --- a/src/main/automations/external-job-mappers.ts +++ b/src/main/automations/external-job-mappers.ts @@ -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 diff --git a/src/main/automations/external-manager-discovery.ts b/src/main/automations/external-manager-discovery.ts index 9c5d50ace68..f876b07eaaf 100644 --- a/src/main/automations/external-manager-discovery.ts +++ b/src/main/automations/external-manager-discovery.ts @@ -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 = { - hermes: 'Hermes', openclaw: 'OpenClaw' } @@ -23,36 +18,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -async function readLocalHermesJobs(): Promise { - 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 { if (!existsSync(OPENCLAW_JOBS_FILE)) { return [] @@ -62,20 +27,12 @@ async function readLocalOpenClawJobs(): Promise { 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 { 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) } } diff --git a/src/main/automations/external-manager-local-command.ts b/src/main/automations/external-manager-local-command.ts index f4844370cbb..4c1b1ad36d5 100644 --- a/src/main/automations/external-manager-local-command.ts +++ b/src/main/automations/external-manager-local-command.ts @@ -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 { return runLocalProviderCommand(command, args, { timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS, diff --git a/src/main/automations/external-manager-mutations.ts b/src/main/automations/external-manager-mutations.ts index 5984481a46a..83192fd010d 100644 --- a/src/main/automations/external-manager-mutations.ts +++ b/src/main/automations/external-manager-mutations.ts @@ -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 > = { - 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 { - 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 { - 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 { @@ -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) diff --git a/src/main/automations/external-manager-runs.ts b/src/main/automations/external-manager-runs.ts deleted file mode 100644 index b6b63efc401..00000000000 --- a/src/main/automations/external-manager-runs.ts +++ /dev/null @@ -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 { - 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 ?? [] - } -} diff --git a/src/main/automations/external-manager-scoped.test.ts b/src/main/automations/external-manager-scoped.test.ts index 0a15e8b21cb..dadb2ef1e26 100644 --- a/src/main/automations/external-manager-scoped.test.ts +++ b/src/main/automations/external-manager-scoped.test.ts @@ -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('fs') return { ...actual, existsSync: existsSyncMock } }) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock, writeFile: writeFileMock })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => fixtureHome +})) function sshTarget(overrides: Partial = {}): 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() - }) }) diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index b8a69c9478b..25d63ac25a2 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -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() }) }) diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 6e04f9add83..b3ab91e4239 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -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 - listRuns: (request: ScopedExternalManagerRunsRequest) => Promise - create: (request: ScopedExternalManagerCreateRequest) => Promise - update: (request: ScopedExternalManagerUpdateRequest) => Promise runAction: (request: ScopedExternalManagerActionRequest) => Promise } @@ -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({ diff --git a/src/main/automations/headless-automation-agent-launch.test.ts b/src/main/automations/headless-automation-agent-launch.test.ts index a17ac5b339d..c5e7c78d5b4 100644 --- a/src/main/automations/headless-automation-agent-launch.test.ts +++ b/src/main/automations/headless-automation-agent-launch.test.ts @@ -65,6 +65,20 @@ function launch(runtime: ReturnType, overrides: Record { + 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() diff --git a/src/main/automations/hermes-cron-output-markdown.ts b/src/main/automations/hermes-cron-output-markdown.ts deleted file mode 100644 index a698458ca8d..00000000000 --- a/src/main/automations/hermes-cron-output-markdown.ts +++ /dev/null @@ -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*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i -export function isRecord(value: unknown): value is Record { - 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 -} diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts deleted file mode 100644 index 55ddf8a5f58..00000000000 --- a/src/main/automations/hermes-cron-output.test.ts +++ /dev/null @@ -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[], - messages: [] as Record[] -})) -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 { - 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: [] - }) - }) -}) diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts deleted file mode 100644 index d30babfceb9..00000000000 --- a/src/main/automations/hermes-cron-output.ts +++ /dev/null @@ -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 { - return (await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: Number.MAX_SAFE_INTEGER })) - .runs -} - -async function readHermesCronOutputRunRefs(jobId: string): Promise { - 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 { - return readCachedHermesRunCount(jobId, async (targetJobId) => { - const refs = await readHermesCronOutputRunRefs(targetJobId) - return refs.length - }) -} - -async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { - 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 { - 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 { - 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 { - 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[] - 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 | 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[] - 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 - } -} diff --git a/src/main/automations/hermes-cron-run-content.ts b/src/main/automations/hermes-cron-run-content.ts deleted file mode 100644 index 1920e18e0d6..00000000000 --- a/src/main/automations/hermes-cron-run-content.ts +++ /dev/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 { - 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 | 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 - })) - ] -} diff --git a/src/main/automations/hermes-cron-run-count-cache.ts b/src/main/automations/hermes-cron-run-count-cache.ts deleted file mode 100644 index 06c45d54d44..00000000000 --- a/src/main/automations/hermes-cron-run-count-cache.ts +++ /dev/null @@ -1,64 +0,0 @@ -const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 -const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 - -type HermesRunCountCacheEntry = { - promise: Promise - expiresAt: number -} - -const hermesRunCountCache = new Map() - -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 -): Promise { - 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 - } -} diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts index 8eda02ecc55..ce283ae7f7d 100644 --- a/src/main/ipc/automations-external-scope.test.ts +++ b/src/main/ipc/automations-external-scope.test.ts @@ -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()), - 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 diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index af3614d0e57..7d44a522b42 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -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 => scopedExternal.listManager(requireCapturedOwner(request)) ) - ipcMain.handle( - 'automations:listExternalRunsForOwner', - (_event, request: ScopedExternalManagerRunsRequest): Promise => - scopedExternal.listRuns(requireCapturedOwner(request)) - ) - ipcMain.handle( - 'automations:createExternalForOwner', - (_event, request: ScopedExternalManagerCreateRequest): Promise => - scopedExternal.create(requireCapturedOwner(request)) - ) - ipcMain.handle( - 'automations:updateExternalForOwner', - (_event, request: ScopedExternalManagerUpdateRequest): Promise => - scopedExternal.update(requireCapturedOwner(request)) - ) ipcMain.handle( 'automations:runExternalActionForOwner', (_event, request: ScopedExternalManagerActionRequest): Promise => diff --git a/src/preload/api/automation-api.ts b/src/preload/api/automation-api.ts index 794f8067221..9c5a8a09169 100644 --- a/src/preload/api/automation-api.ts +++ b/src/preload/api/automation-api.ts @@ -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 - listExternalRunsForOwner: ( - request: ScopedExternalManagerRunsRequest - ) => Promise - createExternalForOwner: (request: ScopedExternalManagerCreateRequest) => Promise - updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest) => Promise runExternalActionForOwner: (request: ScopedExternalManagerActionRequest) => Promise retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }) => Promise runPrecheck: (args: { diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts index 43c3df528d8..b5a4ff3c701 100644 --- a/src/preload/api/automations-bridge.ts +++ b/src/preload/api/automations-bridge.ts @@ -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 => ipcRenderer.invoke('automations:listExternalManagerForOwner', request), - listExternalRunsForOwner: ( - request: ScopedExternalManagerRunsRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalRunsForOwner', request), - createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => - ipcRenderer.invoke('automations:createExternalForOwner', request), - updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => - ipcRenderer.invoke('automations:updateExternalForOwner', request), runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => ipcRenderer.invoke('automations:runExternalActionForOwner', request), retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index f285a4f9ca6..20051355da7 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -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 @@ -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(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({ > { onApplyTemplate(template) @@ -196,8 +180,6 @@ export function AutomationEditorDialog({ /> 0} diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 234a71efae3..4e754da0a71 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -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 (
diff --git a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx index a36e4ab0b5b..9443c437813 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx @@ -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 ( {title} {isCreateMode ? (
- - value && onCreateTargetChange(value as AutomationCreateTarget) - } - size="sm" - className={segmentedGroupClassName} - > - - {translate( - 'auto.components.automations.AutomationEditorDialogHeader.6f309eef8d', - 'Orca' - )} - - - {translate( - 'auto.components.automations.AutomationEditorDialogHeader.7e35393632', - 'Hermes' - )} - -
- {selectedExternalRunPage ? ( - - - - ) : ( - - )} + } + ]} + now={relativeNow} + runningActionKey={externalActionKey} + onAction={requestExternalAction} + />
) : ( undefined} requestDeleteAutomation={() => undefined} requestExternalAction={() => undefined} - openEditExternalDialog={() => undefined} openCreateDialog={() => undefined} onOpenDetail={options.onOpenDetail ?? (() => undefined)} onRefresh={() => undefined} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 3460aafeb2a..9651e0914da 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -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} /> ) )} diff --git a/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx index b4ea3413cb3..f86cdd174fa 100644 --- a/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx @@ -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() diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx index a62a433a4d1..e8830b40103 100644 --- a/src/renderer/src/components/automations/AutomationsPage.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -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('[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), 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 () => { diff --git a/src/renderer/src/components/automations/AutomationsPageListPanel.tsx b/src/renderer/src/components/automations/AutomationsPageListPanel.tsx index 7adea56c608..9b9acc1ffd3 100644 --- a/src/renderer/src/components/automations/AutomationsPageListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsPageListPanel.tsx @@ -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} diff --git a/src/renderer/src/components/automations/AutomationsPageSurface.tsx b/src/renderer/src/components/automations/AutomationsPageSurface.tsx index 1e46b5f0283..05d0b55811c 100644 --- a/src/renderer/src/components/automations/AutomationsPageSurface.tsx +++ b/src/renderer/src/components/automations/AutomationsPageSurface.tsx @@ -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({ 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') }} /> diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx index 06c7c66f099..5a3d5e8ecf1 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx @@ -41,7 +41,7 @@ function makeJob(overrides: Partial = {}): 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 = {}): ExternalAutoma function makeManager( overrides: Partial = {} ): 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( ) }) - 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() diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index 815d3918215..eea38d9fe7d 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -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} - {manager.provider === 'hermes' ? ( -
- {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' - )} -
- ) : null} {job.promptPreview || job.lastError ? (
{job.lastError ?? job.promptPreview} @@ -284,21 +247,6 @@ export function ExternalAutomationManagers({ )} - {manager.provider === 'hermes' ? ( - onEdit?.(manager, job, scope)} - > - - - ) : null}
- {manager.provider === 'hermes' ? ( -
- onOpenRun?.(manager, job, run)} - /> -
- ) : null} ) })} @@ -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.' diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx deleted file mode 100644 index 69f24c94ce5..00000000000 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ /dev/null @@ -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 - -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 ( -
-
-
-
- {translate('auto.components.automations.ExternalAutomationRunTable.2d4388a908', 'Runs')} -
- {isLoading ? : null} - {fetchError ? ( - - - - - - {fetchError} - - - ) : null} -
-
- {totalCount}{' '} - {totalCount === 1 - ? translate('auto.components.automations.ExternalAutomationRunTable.872d032d05', 'run') - : translate( - 'auto.components.automations.ExternalAutomationRunTable.d5527d8fe7', - 'runs' - )} -
-
- - {hasVisibleRuns ? ( -
-
-
- - {translate( - 'auto.components.automations.ExternalAutomationRunTable.d4b34feb66', - 'Run time' - )} - - - {translate( - 'auto.components.automations.ExternalAutomationRunTable.a813df9808', - 'Preview' - )} - - - {translate( - 'auto.components.automations.ExternalAutomationRunTable.be551397ca', - 'Status' - )} - -
-
- {visibleRuns.map((run) => ( - - ))} -
-
-
- ) : ( -
- {isLoading - ? translate( - 'auto.components.automations.ExternalAutomationRunTable.8ea934cacf', - 'Loading runs...' - ) - : translate( - 'auto.components.automations.ExternalAutomationRunTable.9c080765ff', - 'No Hermes runs found yet.' - )} -
- )} - -
-
- - - {pageStart}-{pageEnd}{' '} - {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}{' '} - {totalCount} - -
-
- -
- {page + 1} / {totalPages} -
- -
-
-
- ) -} diff --git a/src/renderer/src/components/automations/HermesCronOutputView.tsx b/src/renderer/src/components/automations/HermesCronOutputView.tsx deleted file mode 100644 index 83acd213ddc..00000000000 --- a/src/renderer/src/components/automations/HermesCronOutputView.tsx +++ /dev/null @@ -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 ( -
- - {open ?
{children}
: null} -
- ) -} - -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 ( -
-
- {accent !== 'default' ? : null} - {title} -
-
{children}
-
- ) -} - -function MetadataValue({ label, value }: { label: string; value: string }): React.JSX.Element { - const scheduleDisplay = isScheduleMetadataLabel(label) ? getScheduleDisplay(value) : null - - if (!scheduleDisplay) { - return
{value}
- } - - return ( -
{scheduleDisplay}
- ) -} - -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 ( - - ) - } - - return ( -
- {parsed.metadata.length > 0 ? ( -
- {parsed.metadata.map((entry) => { - const { icon: Icon, iconClass, ringClass } = getMetadataIconStyle(entry.label) - return ( -
- - - -
-
- {getMetadataDisplayLabel(entry.label)} -
- -
-
- ) - })} -
- ) : null} - - {errorSection ? ( - - - - ) : null} - - {responseSection ? ( - - - - ) : null} - - {promptSection ? ( - - - - ) : null} - - {otherSections.map((section) => ( - - - - ))} -
- ) -} diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts index af2ada997b1..76d93b0e3e2 100644 --- a/src/renderer/src/components/automations/automation-draft-model.ts +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -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 } diff --git a/src/renderer/src/components/automations/automation-edit-draft.ts b/src/renderer/src/components/automations/automation-edit-draft.ts index 8f7a681d822..84b49e8c2eb 100644 --- a/src/renderer/src/components/automations/automation-edit-draft.ts +++ b/src/renderer/src/components/automations/automation-edit-draft.ts @@ -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.' - } -} diff --git a/src/renderer/src/components/automations/automation-external-target-match.test.ts b/src/renderer/src/components/automations/automation-external-target-match.test.ts deleted file mode 100644 index 25cdaa65eb8..00000000000 --- a/src/renderer/src/components/automations/automation-external-target-match.test.ts +++ /dev/null @@ -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 { - 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) - }) -}) diff --git a/src/renderer/src/components/automations/automation-external-target-match.ts b/src/renderer/src/components/automations/automation-external-target-match.ts deleted file mode 100644 index f544c324eb1..00000000000 --- a/src/renderer/src/components/automations/automation-external-target-match.ts +++ /dev/null @@ -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, - target: ExternalAutomationTarget -): boolean { - const repoHostId = getRepoExecutionHostId(repo) - if (target.type === 'local') { - return parseExecutionHostId(repoHostId)?.kind === 'local' - } - return repoHostId === toSshExecutionHostId(target.connectionId) -} diff --git a/src/renderer/src/components/automations/automation-hermes-save.ts b/src/renderer/src/components/automations/automation-hermes-save.ts deleted file mode 100644 index a472440e5f7..00000000000 --- a/src/renderer/src/components/automations/automation-hermes-save.ts +++ /dev/null @@ -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 { - 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.' - ) - ) -} diff --git a/src/renderer/src/components/automations/automation-list-last-run.test.ts b/src/renderer/src/components/automations/automation-list-last-run.test.ts index 7eb54e123a5..901cacaa548 100644 --- a/src/renderer/src/components/automations/automation-list-last-run.test.ts +++ b/src/renderer/src/components/automations/automation-list-last-run.test.ts @@ -69,7 +69,7 @@ function makeJob(overrides: Partial = {}): ExternalAutoma return { id: 'job-1', managerId: 'manager-1', - provider: 'hermes', + provider: 'openclaw', name: 'Digest', schedule: '0 9 * * 1-5', rawSchedule: null, diff --git a/src/renderer/src/components/automations/automation-list-search-rows.test.ts b/src/renderer/src/components/automations/automation-list-search-rows.test.ts index c81571fcb2f..27db2b6a558 100644 --- a/src/renderer/src/components/automations/automation-list-search-rows.test.ts +++ b/src/renderer/src/components/automations/automation-list-search-rows.test.ts @@ -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') }) diff --git a/src/renderer/src/components/automations/automation-list-view.test.ts b/src/renderer/src/components/automations/automation-list-view.test.ts index 169fbdd360a..a899d81d5db 100644 --- a/src/renderer/src/components/automations/automation-list-view.test.ts +++ b/src/renderer/src/components/automations/automation-list-view.test.ts @@ -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, diff --git a/src/renderer/src/components/automations/automation-orca-save.ts b/src/renderer/src/components/automations/automation-orca-save.ts index 67e884121b3..16b94c92423 100644 --- a/src/renderer/src/components/automations/automation-orca-save.ts +++ b/src/renderer/src/components/automations/automation-orca-save.ts @@ -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, diff --git a/src/renderer/src/components/automations/automation-page-state.ts b/src/renderer/src/components/automations/automation-page-state.ts index c273c778038..72638854e0d 100644 --- a/src/renderer/src/components/automations/automation-page-state.ts +++ b/src/renderer/src/components/automations/automation-page-state.ts @@ -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 -} diff --git a/src/renderer/src/components/automations/automation-save-action.ts b/src/renderer/src/components/automations/automation-save-action.ts index 6c5c961ab65..0463f62ba64 100644 --- a/src/renderer/src/components/automations/automation-save-action.ts +++ b/src/renderer/src/components/automations/automation-save-action.ts @@ -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 { 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 diff --git a/src/renderer/src/components/automations/automation-setup-decision.test.ts b/src/renderer/src/components/automations/automation-setup-decision.test.ts index 87d1bf19a14..bd92c0cc5da 100644 --- a/src/renderer/src/components/automations/automation-setup-decision.test.ts +++ b/src/renderer/src/components/automations/automation-setup-decision.test.ts @@ -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: [ diff --git a/src/renderer/src/components/automations/automation-setup-decision.ts b/src/renderer/src/components/automations/automation-setup-decision.ts index 9df3f0298fc..d05c06792ca 100644 --- a/src/renderer/src/components/automations/automation-setup-decision.ts +++ b/src/renderer/src/components/automations/automation-setup-decision.ts @@ -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 | 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 | undefined }): Extract | undefined { - if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + if (args.workspaceMode !== 'new_per_run') { return undefined } diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index a5a3175e467..81186c9953e 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -121,9 +121,9 @@ export function makeExternalManager( overrides: Partial = {} ): 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 } } diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index 0a19dc67931..50d77e55319 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -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 } export type EditorDialogProps = { @@ -286,9 +274,6 @@ export const api = { 'runNow', 'listScoped', 'listExternalManagerForOwner', - 'listExternalRunsForOwner', - 'createExternalForOwner', - 'updateExternalForOwner', 'runExternalActionForOwner', 'retainExternalScopes' ), diff --git a/src/renderer/src/components/automations/external-automation-display.test.ts b/src/renderer/src/components/automations/external-automation-display.test.ts deleted file mode 100644 index 7614352e377..00000000000 --- a/src/renderer/src/components/automations/external-automation-display.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts index 388598269ea..87e3c167275 100644 --- a/src/renderer/src/components/automations/external-automation-display.ts +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -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['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) - ) -} diff --git a/src/renderer/src/components/automations/external-automation-list-entries.test.ts b/src/renderer/src/components/automations/external-automation-list-entries.test.ts index d2609ebb834..e93f1461068 100644 --- a/src/renderer/src/components/automations/external-automation-list-entries.test.ts +++ b/src/renderer/src/components/automations/external-automation-list-entries.test.ts @@ -5,9 +5,9 @@ import type { ScopedExternalAutomationManager } from './external-automation-scop function manager(overrides: Partial = {}): 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 = {}): 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) diff --git a/src/renderer/src/components/automations/external-automation-run-table-state.test.ts b/src/renderer/src/components/automations/external-automation-run-table-state.test.ts deleted file mode 100644 index 10da77d348f..00000000000 --- a/src/renderer/src/components/automations/external-automation-run-table-state.test.ts +++ /dev/null @@ -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 - }) - }) -}) diff --git a/src/renderer/src/components/automations/external-automation-run-table-state.ts b/src/renderer/src/components/automations/external-automation-run-table-state.ts deleted file mode 100644 index ef25c085049..00000000000 --- a/src/renderer/src/components/automations/external-automation-run-table-state.ts +++ /dev/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 - } -} diff --git a/src/renderer/src/components/automations/external-automation-scope-client.ts b/src/renderer/src/components/automations/external-automation-scope-client.ts index 7b50d45636c..f7522dcd16d 100644 --- a/src/renderer/src/components/automations/external-automation-scope-client.ts +++ b/src/renderer/src/components/automations/external-automation-scope-client.ts @@ -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 { - await window.api.automations.createExternalForOwner({ ...scope, ...fields }) -} - -export async function updateScopedExternalAutomation( - scope: ExternalAutomationScope, - jobId: string, - fields: ScopedExternalManagerMutationFields -): Promise { - 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 { 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 { - 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 } -} diff --git a/src/renderer/src/components/automations/external-automation-scope-keys.test.ts b/src/renderer/src/components/automations/external-automation-scope-keys.test.ts index 7209af24a1a..5c18efd6285 100644 --- a/src/renderer/src/components/automations/external-automation-scope-keys.test.ts +++ b/src/renderer/src/components/automations/external-automation-scope-keys.test.ts @@ -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) }) diff --git a/src/renderer/src/components/automations/external-automation-scope-keys.ts b/src/renderer/src/components/automations/external-automation-scope-keys.ts index 64362e803ef..0ce5b185226 100644 --- a/src/renderer/src/components/automations/external-automation-scope-keys.ts +++ b/src/renderer/src/components/automations/external-automation-scope-keys.ts @@ -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) -} diff --git a/src/renderer/src/components/automations/external-automation-source-availability.test.ts b/src/renderer/src/components/automations/external-automation-source-availability.test.ts index 34dd5e7be2f..9c36ab6f9d5 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.test.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.test.ts @@ -4,9 +4,9 @@ import { getExternalAutomationActionDisabledMessage } from './external-automatio function manager(overrides: Partial = {}): 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', () => { diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts index 53898b8433c..d13369d0c9c 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -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) } diff --git a/src/renderer/src/components/automations/hermes-cron-output-parse.test.ts b/src/renderer/src/components/automations/hermes-cron-output-parse.test.ts deleted file mode 100644 index ff6a68d71b0..00000000000 --- a/src/renderer/src/components/automations/hermes-cron-output-parse.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/automations/hermes-cron-output-parse.ts b/src/renderer/src/components/automations/hermes-cron-output-parse.ts deleted file mode 100644 index cd00a2a6058..00000000000 --- a/src/renderer/src/components/automations/hermes-cron-output-parse.ts +++ /dev/null @@ -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 - } -} diff --git a/src/renderer/src/components/automations/use-automation-draft-effects.ts b/src/renderer/src/components/automations/use-automation-draft-effects.ts index a698aed53e9..02bd35ca9f8 100644 --- a/src/renderer/src/components/automations/use-automation-draft-effects.ts +++ b/src/renderer/src/components/automations/use-automation-draft-effects.ts @@ -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 diff --git a/src/renderer/src/components/automations/use-automation-editor-actions.ts b/src/renderer/src/components/automations/use-automation-editor-actions.ts index 887d4f2216a..a0c44035216 100644 --- a/src/renderer/src/components/automations/use-automation-editor-actions.ts +++ b/src/renderer/src/components/automations/use-automation-editor-actions.ts @@ -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 => { 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 } diff --git a/src/renderer/src/components/automations/use-automation-run-page-state.ts b/src/renderer/src/components/automations/use-automation-run-page-state.ts index dc80ac2fca3..c1829dfaf42 100644 --- a/src/renderer/src/components/automations/use-automation-run-page-state.ts +++ b/src/renderer/src/components/automations/use-automation-run-page-state.ts @@ -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) { diff --git a/src/renderer/src/components/automations/use-automations-page-controller.ts b/src/renderer/src/components/automations/use-automations-page-controller.ts index 70214a367b4..94097c7e1de 100644 --- a/src/renderer/src/components/automations/use-automations-page-controller.ts +++ b/src/renderer/src/components/automations/use-automations-page-controller.ts @@ -55,7 +55,6 @@ export function useAutomationsPageController() { destination }) const draftEffects = useAutomationDraftEffects({ - store, local, setup, destination, diff --git a/src/renderer/src/components/automations/use-automations-page-destination-form.ts b/src/renderer/src/components/automations/use-automations-page-destination-form.ts index c9f91cb2f37..354a8186c0d 100644 --- a/src/renderer/src/components/automations/use-automations-page-destination-form.ts +++ b/src/renderer/src/components/automations/use-automations-page-destination-form.ts @@ -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, diff --git a/src/renderer/src/components/automations/use-automations-page-destination-state.ts b/src/renderer/src/components/automations/use-automations-page-destination-state.ts index 947d28182ef..3c08627fa80 100644 --- a/src/renderer/src/components/automations/use-automations-page-destination-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-destination-state.ts @@ -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, diff --git a/src/renderer/src/components/automations/use-automations-page-escape.ts b/src/renderer/src/components/automations/use-automations-page-escape.ts index be40b8b3fe0..9f65c3649c6 100644 --- a/src/renderer/src/components/automations/use-automations-page-escape.ts +++ b/src/renderer/src/components/automations/use-automations-page-escape.ts @@ -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 ]) } diff --git a/src/renderer/src/components/automations/use-automations-page-list-state.ts b/src/renderer/src/components/automations/use-automations-page-list-state.ts index b13c8a689ec..bb1d833de38 100644 --- a/src/renderer/src/components/automations/use-automations-page-list-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-list-state.ts @@ -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]) diff --git a/src/renderer/src/components/automations/use-automations-page-local-state.ts b/src/renderer/src/components/automations/use-automations-page-local-state.ts index bef40d27290..5b20cb61e7b 100644 --- a/src/renderer/src/components/automations/use-automations-page-local-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-local-state.ts @@ -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(EMPTY_AUTOMATION_LIST_FILTER) const [listSort, setListSort] = useState(null) const [createOpen, setCreateOpen] = useState(false) - const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) const [editingRowKey, setEditingRowKey] = useState(null) const [editingDestination, setEditingDestination] = useState<{ @@ -78,8 +76,6 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) { ) const [selectedRowKey, setSelectedRowKey] = useState(null) const [selectedExternalKey, setSelectedExternalKey] = useState(null) - const [selectedExternalRunPage, setSelectedExternalRunPage] = - useState(null) const [isDetailOpen, setIsDetailOpen] = useState(false) const selectedExternalKeyRef = useRef(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(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(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, diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index bf35d6f686d..782ffe7567a 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -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): string => [ - createTarget, candidate.workspaceMode, candidate.projectId, getDraftSetupDecisionDefault(candidate) ?? 'none' ].join(':'), - [createTarget, getDraftSetupDecisionDefault] + [getDraftSetupDecisionDefault] ) const markSetupDecisionTouched = useCallback((): void => { setupDecisionTouchedRef.current = true diff --git a/src/renderer/src/components/automations/use-external-automation-actions.ts b/src/renderer/src/components/automations/use-external-automation-actions.ts index 99cd8821f6a..122b46f7c74 100644 --- a/src/renderer/src/components/automations/use-external-automation-actions.ts +++ b/src/renderer/src/components/automations/use-external-automation-actions.ts @@ -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( - 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 diff --git a/src/renderer/src/components/automations/use-scoped-external-automations.ts b/src/renderer/src/components/automations/use-scoped-external-automations.ts index 03a033fca3c..e95315d7bdf 100644 --- a/src/renderer/src/components/automations/use-scoped-external-automations.ts +++ b/src/renderer/src/components/automations/use-scoped-external-automations.ts @@ -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 - /** 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 runExternalAction: ( scope: ExternalAutomationScope, jobId: string, action: ExternalAutomationAction ) => Promise - fetchRuns: ( - scope: ExternalAutomationScope, - job: ExternalAutomationJob, - page: number, - pageSize: number - ) => Promise<{ runs: readonly ExternalAutomationRunRow[]; totalCount: number }> } -type ExternalAutomationRunRow = Awaited< - ReturnType ->['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), - [] ) } } diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 89b3cfa74c0..6792ce7a601 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -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 diff --git a/src/shared/external-automation-scope.ts b/src/shared/external-automation-scope.ts index e7663dbd16e..cf6ecdb75bd 100644 --- a/src/shared/external-automation-scope.ts +++ b/src/shared/external-automation-scope.ts @@ -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 = { [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 diff --git a/src/shared/hermes-run-ref-retention.ts b/src/shared/hermes-run-ref-retention.ts deleted file mode 100644 index 173aa60f33d..00000000000 --- a/src/shared/hermes-run-ref-retention.ts +++ /dev/null @@ -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 = { - refs: T[] - saturated: boolean -} - -export class HermesRunRefRetainer { - 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 { - 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) -} diff --git a/src/shared/hermes-session-run-index.test.ts b/src/shared/hermes-session-run-index.test.ts deleted file mode 100644 index 260eec18630..00000000000 --- a/src/shared/hermes-session-run-index.test.ts +++ /dev/null @@ -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, - 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() - 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() -}) diff --git a/src/shared/hermes-session-run-index.ts b/src/shared/hermes-session-run-index.ts deleted file mode 100644 index 7916214f20a..00000000000 --- a/src/shared/hermes-session-run-index.ts +++ /dev/null @@ -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() - private readonly exact = new Map() - private readonly timed: { time: number; index: number }[] = [] - private readonly positionByIndex = new Map() - 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 - } -}