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