Files
orca/src/main/persistence-automations.test.ts
T
Neil eedca5ee1c fix(persistence): worktree meta and lineage writes take the row's host, not the id (R4-2b) (#30811)
* fix(persistence): worktree meta and lineage writes take the row's host, not the id (R4-2b)

* fix(persistence): review fixes for host-targeted worktree writes

* test: SAFETY notes on partial runtime store mocks

* test: drop imports duplicated by the rebase onto #30797
2026-10-19 11:39:54 -07:00

840 lines
28 KiB
TypeScript

import {
closeTestStores,
testState,
createStore,
writeDataFile,
readDataFile,
makeRepo
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { PersistedState } from '../shared/persisted-state-types'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host'
import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity'
import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
}))
vi.mock('./ssh/config/ssh-config-parser', () => ({
loadUserSshConfig: loadUserSshConfigMock,
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
}))
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => {
const decoded = ciphertext.toString('utf-8')
if (!decoded.startsWith('encrypted:')) {
throw new Error('invalid ciphertext')
}
return decoded.slice('encrypted:'.length)
}
}
}))
vi.mock('./telemetry/client', () => ({
track: trackMock
}))
vi.mock('./telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
describe('Store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it('can clear an automation back to the project default branch', async () => {
const store = await createStore()
store.addRepo(makeRepo({ worktreeBaseRef: 'origin/main' }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
baseBranch: 'origin/release',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const updated = store.updateAutomation(automation.id, { baseBranch: null })
expect(updated.baseBranch).toBeNull()
store.flush()
const persisted = readDataFile() as { automations: { baseBranch: string | null }[] }
expect(persisted.automations[0].baseBranch).toBeNull()
})
it('records the dispatch time a headless launch stamped before prompting its agent', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, Date.now())
const dispatched = store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
dispatchedAt: 1_234,
error: null
})
expect(dispatched.dispatchedAt).toBe(1_234)
})
it('returns the existing automation for a repeated creation key', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const input = {
creationKey: 'move-retry-1',
name: 'Retry-safe move',
prompt: 'Run checks',
agentId: 'claude' as const,
projectId: 'r1',
workspaceMode: 'new_per_run' as const,
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
}
const first = store.createAutomation(input)
const retry = store.createAutomation({ ...input, name: 'Changed by a retry' })
expect(retry.id).toBe(first.id)
expect(store.listAutomations()).toHaveLength(1)
})
it('persists session reuse only for existing-workspace automations', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const existingWorkspace = store.createAutomation({
name: 'Digest',
prompt: 'Summarize changes',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
reuseSession: true,
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const newPerRun = store.createAutomation({
name: 'Fresh',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
reuseSession: true,
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
expect(existingWorkspace.reuseSession).toBe(true)
expect(newPerRun.reuseSession).toBe(false)
expect(
store.updateAutomation(existingWorkspace.id, { workspaceMode: 'new_per_run' }).reuseSession
).toBe(false)
const persisted = readDataFile() as { automations: Record<string, unknown>[] }
delete persisted.automations[0].reuseSession
writeDataFile(persisted)
const reloaded = await createStore()
expect(reloaded.listAutomations()[0].reuseSession).toBe(false)
})
it('persists setup decisions only for new-per-run automations', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const newPerRun = store.createAutomation({
name: 'Fresh',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
setupDecision: 'run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const existing = store.createAutomation({
name: 'Reuse',
prompt: 'Summarize changes',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
setupDecision: 'run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
expect(newPerRun.setupDecision).toBe('run')
expect(existing.setupDecision).toBeUndefined()
const skipped = store.updateAutomation(newPerRun.id, { setupDecision: 'skip' })
const switchedToExisting = store.updateAutomation(newPerRun.id, {
workspaceMode: 'existing',
workspaceId: 'wt1'
})
expect(skipped.setupDecision).toBe('skip')
expect(switchedToExisting.setupDecision).toBeUndefined()
expect(
store.updateAutomation(existing.id, { workspaceMode: 'new_per_run', setupDecision: 'run' })
.setupDecision
).toBe('run')
})
it('treats undefined update fields as omitted, never as explicit clears', async () => {
// The renderer forwards a Partial verbatim, so an untouched field arrives as explicit undefined
// and must not take the `null` clear branch reserved for a real user clear.
const store = await createStore()
store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
baseBranch: 'origin/release',
setupDecision: 'run',
precheck: { command: 'pnpm lint', timeoutSeconds: 30 },
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const updated = store.updateAutomation(automation.id, {
precheck: undefined,
runContext: undefined,
sourceContext: undefined,
baseBranch: undefined,
setupDecision: undefined
})
expect(updated.precheck).toEqual(automation.precheck)
expect(updated.runContext).toEqual(automation.runContext)
expect(updated.sourceContext).toEqual(automation.sourceContext)
expect(updated.baseBranch).toBe('origin/release')
expect(updated.setupDecision).toBe('run')
// Explicit nulls still clear.
expect(store.updateAutomation(automation.id, { baseBranch: null }).baseBranch).toBeNull()
const existing = store.updateAutomation(automation.id, {
workspaceMode: 'existing',
workspaceId: 'wt1'
})
expect(store.updateAutomation(existing.id, { workspaceId: undefined }).workspaceId).toBe('wt1')
})
it('derives automation source and run contexts from the project host setup', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
connectionId: 'builder'
})
)
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
expect(automation.runContext).toMatchObject({
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
path: '/repo'
})
expect(automation.sourceContext).toMatchObject({
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
})
})
it('stores its own contexts over a client-perspective create runContext', async () => {
const store = await createStore()
store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime(),
// A paired client names this host 'runtime:<id>' — an id the client assigned,
// which this store cannot interpret and must not persist.
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: toRuntimeExecutionHostId('client-env'),
projectHostSetupId: 'client-setup',
repoId: 'client-repo',
path: '/client/checkout'
}
})
expect(automation.runContext).toMatchObject({ hostId: 'local', repoId: 'r1' })
expect(store.listAutomationsForScope().items[0]?.selector).toEqual({ kind: 'self' })
})
it('re-derives a stored client-perspective context on an explicit move, not on a toggle', async () => {
const store = await createStore()
store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const persisted = readDataFile() as { automations: Record<string, unknown>[] }
// A record a pre-fix host stored from a paired client's create input.
persisted.automations[0].runContext = {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: toRuntimeExecutionHostId('client-env'),
projectHostSetupId: 'client-setup',
repoId: 'client-repo',
path: '/client/checkout'
}
writeDataFile(persisted)
const reloaded = await createStore()
expect(reloaded.listAutomationsForScope().items[0]?.selector.kind).toBe('orphan')
// A toggle is not a move: the record must not silently re-adopt.
reloaded.updateAutomation(automation.id, { enabled: false })
expect(reloaded.listAutomationsForScope().items[0]?.selector.kind).toBe('orphan')
const healed = reloaded.updateAutomation(automation.id, { projectId: 'r1' })
expect(healed.runContext).toMatchObject({ hostId: 'local', repoId: 'r1' })
expect(reloaded.listAutomationsForScope().items[0]?.selector).toEqual({ kind: 'self' })
})
it('marks runtime-owned automations as remote-host scheduled', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
executionHostId: toRuntimeExecutionHostId('gpu-server'),
upstream: { owner: 'stablyai', repo: 'orca' }
})
)
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
expect(automation.schedulerOwner).toBe('remote_host_service')
expect(automation.runContext).toMatchObject({
hostId: toRuntimeExecutionHostId('gpu-server')
})
})
it('snapshots automation contexts onto runs', async () => {
const store = await createStore()
store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
store.updateAutomation(automation.id, { sourceContext: null, runContext: null })
expect(run.runContext).toEqual(automation.runContext)
expect(run.sourceContext).toEqual(automation.sourceContext)
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
runContext: automation.runContext,
sourceContext: automation.sourceContext
})
})
it('backfills legacy automation contexts on load', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
connectionId: 'builder'
})
)
const automation = store.createAutomation({
name: 'Legacy nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const persisted = readDataFile() as {
automations: Record<string, unknown>[]
automationRuns: Record<string, unknown>[]
}
delete persisted.automations[0].runContext
delete persisted.automations[0].sourceContext
delete persisted.automationRuns[0].runContext
delete persisted.automationRuns[0].sourceContext
writeDataFile(persisted)
const reloaded = await createStore()
const migratedAutomation = reloaded
.listAutomations()
.find((entry) => entry.id === automation.id)
const migratedRun = reloaded
.listAutomationRuns(automation.id)
.find((entry) => entry.id === run.id)
expect(migratedAutomation?.runContext).toMatchObject({
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
path: '/repo'
})
expect(migratedAutomation?.sourceContext).toMatchObject({
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
})
expect(migratedRun?.runContext).toEqual(migratedAutomation?.runContext)
expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext)
})
it('shrinks an oversized automationRuns file on load without any later mutation', async () => {
const seed = await createStore()
seed.addRepo(
makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
connectionId: 'builder'
})
)
const automation = seed.createAutomation({
name: 'Every minute',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
seed.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
seed.flush()
// Why: a second load+flush settles the one-shot UI migration flags first, so they (not the prune) don't mark the store dirty.
const warm = await createStore()
warm.flush()
const persisted = readDataFile() as { automationRuns: Record<string, unknown>[] }
const template = persisted.automationRuns[0]
persisted.automationRuns = Array.from({ length: 250 }, (_, i) => {
const legacy: Record<string, unknown> = {
...template,
id: `legacy-run-${i}`,
// Only final runs are evictable; the real-world blowup was skipped_precheck rows.
status: 'skipped_precheck',
createdAt: 1_000 + i,
scheduledFor: 1_000 + i
}
// Legacy files predate runNumber; backfill must run BEFORE the prune so survivors keep their true ordinals.
delete legacy.runNumber
return legacy
})
writeDataFile(persisted)
vi.useFakeTimers()
try {
const reloaded = await createStore()
expect(reloaded.listAutomationRuns(automation.id)).toHaveLength(100)
// The load-path prune must mark state dirty on its own; nothing else here saves.
vi.advanceTimersByTime(1000)
await reloaded.waitForPendingWrite()
} finally {
vi.useRealTimers()
}
const healed = readDataFile() as { automationRuns: Record<string, unknown>[] }
expect(healed.automationRuns).toHaveLength(100)
expect(healed.automationRuns.at(-1)?.id).toBe('legacy-run-249')
// Survivors carry their true lifetime ordinals (151..250), not restarted ones.
expect(healed.automationRuns[0]?.runNumber).toBe(151)
expect(healed.automationRuns.at(-1)?.runNumber).toBe(250)
})
it('does not strand an in-flight run whose completion lands after the retention cap', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
connectionId: 'builder'
})
)
const automation = store.createAutomation({
name: 'Every minute',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const base = new Date('2026-05-13T09:00:00Z').getTime()
const inFlight = store.createAutomationRun(automation, base)
store.updateAutomationRun({
runId: inFlight.id,
status: 'dispatched',
workspaceId: null,
error: null
})
// 120 later runs reach a final status while the first one is still dispatched.
let firstCompletedId = ''
for (let i = 1; i <= 120; i++) {
const later = store.createAutomationRun(automation, base + i * 60_000)
firstCompletedId ||= later.id
store.updateAutomationRun({
runId: later.id,
status: 'completed',
workspaceId: null,
error: null
})
}
// The late completion must still find its row.
const completed = store.updateAutomationRun({
runId: inFlight.id,
status: 'completed',
workspaceId: null,
error: null
})
expect(completed.id).toBe(inFlight.id)
const runs = store.listAutomationRuns(automation.id)
expect(runs.some((run) => run.id === inFlight.id)).toBe(true)
// Store can briefly hold cap+2: the last-created run finalizes after its creation-time prune, and the late completion lands without a prune of its own.
expect(runs.some((run) => run.id === firstCompletedId)).toBe(false)
expect(runs.length).toBeLessThanOrEqual(102)
})
it('persists automation precheck config and run results', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Conditional',
prompt: 'Run checks',
precheck: {
command: 'test -f ready',
timeoutSeconds: 30
},
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
store.updateAutomationRun({
runId: run.id,
status: 'skipped_precheck',
precheckResult: {
command: 'test -f ready',
exitCode: 1,
timedOut: false,
durationMs: 12,
stdout: '',
stderr: 'missing',
stdoutTruncated: false,
stderrTruncated: false,
error: null,
startedAt: 10,
completedAt: 22
},
error: 'Precheck exited with code 1.'
})
expect(store.listAutomations()[0].precheck).toEqual({
command: 'test -f ready',
timeoutSeconds: 30
})
expect(store.listAutomationRuns(automation.id)[0].precheckResult).toMatchObject({
exitCode: 1,
stderr: 'missing'
})
expect(store.updateAutomation(automation.id, { precheck: null }).precheck).toBeNull()
})
it('numbers automation run titles per automation', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const first = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const duplicate = store.createAutomationRun(
automation,
new Date('2026-05-13T09:00:00Z').getTime()
)
const second = store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime())
expect(first.title).toBe('Nightly run 1')
expect(duplicate.id).toBe(first.id)
expect(duplicate.title).toBe('Nightly run 1')
expect(second.title).toBe('Nightly run 2')
})
it('records feature interactions when automations are created or manually queued', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime(), 'scheduled')
store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime(), 'manual')
expect(store.getUI().featureInteractions?.['automation-created']?.interactionCount).toBe(1)
expect(store.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1)
const persisted = readDataFile() as PersistedState
expect(persisted.ui?.featureInteractions?.['automation-created']).toMatchObject({
interactionCount: 1
})
expect(persisted.ui?.featureInteractions?.['automation-run']).toMatchObject({
interactionCount: 1
})
})
it('snapshots automation run workspace names for deleted-workspace history', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt1', hostId: 'local' }), {
displayName: 'Nightly workspace'
})
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
removeWorktreeMetaRow(store, 'wt1', undefined)
expect(run.workspaceDisplayName).toBe('Nightly workspace')
expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe(
'Nightly workspace'
)
})
it('backfills automation run workspace names before workspace deletion', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const updatedCount = store.snapshotAutomationRunWorkspaceDisplayName(
hostWorktreeTarget({ id: 'wt1', hostId: 'local' }),
'Deleted workspace'
)
expect(updatedCount).toBe(1)
expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe(
'Deleted workspace'
)
})
it("deleting an SSH twin's workspace leaves the local twin's run label", async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const before = store.listAutomationRuns(automation.id)[0].workspaceDisplayName
const updatedCount = store.snapshotAutomationRunWorkspaceDisplayName(
hostWorktreeTarget({ id: 'wt1', hostId: toSshExecutionHostId('ssh-1') }),
'SSH twin'
)
expect(updatedCount).toBe(0)
expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe(before)
})
it('persists automation run output snapshots across later status updates', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
terminalPaneKey: paneKey,
terminalPtyId: 'pty-run',
outputSnapshot: {
format: 'plain_text',
content: 'Run finished',
capturedAt: 1,
truncated: false
},
error: null
})
store.updateAutomationRun({
runId: run.id,
status: 'completed',
workspaceId: 'wt1',
usage: null,
error: null
})
const persisted = store.listAutomationRuns(automation.id)[0]
expect(persisted.outputSnapshot).toMatchObject({
content: 'Run finished',
truncated: false
})
expect(persisted).toMatchObject({
terminalSessionId: 'tab-1',
terminalPaneKey: paneKey,
terminalPtyId: 'pty-run'
})
})
})