diff --git a/src/main/automations/run-target-resolution.test.ts b/src/main/automations/run-target-resolution.test.ts new file mode 100644 index 000000000..6e48fb0aa --- /dev/null +++ b/src/main/automations/run-target-resolution.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import type { Store } from '../persistence' +import type { Automation } from '../../shared/automations-types' +import type { WorkspaceRunContext } from '../../shared/task-source-context' +import type { ProjectHostSetup, Repo } from '../../shared/types' +import { resolveAutomationRunTarget } from './run-target-resolution' + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#fff', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeSetup(overrides: Partial = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'github:o/r', + hostId: 'local', + repoId: 'repo-1', + path: '/repo', + displayName: 'Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRunContext(overrides: Partial = {}): WorkspaceRunContext { + return { + kind: 'workspace-run', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo', + ...overrides + } +} + +function makeAutomation(runContext: WorkspaceRunContext): Automation { + return { + id: 'automation-1', + name: 'Nightly', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + runContext + } as Automation +} + +function makeStore(setups: ProjectHostSetup[], repos: Repo[]): Store { + return { + getProjectHostSetups: () => setups, + getRepo: (id: string) => repos.find((repo) => repo.id === id) + } as unknown as Store +} + +describe('resolveAutomationRunTarget projectId drift', () => { + it('resolves when only the derived projectId tier differs (repo: snapshot vs github: setup)', () => { + const store = makeStore([makeSetup()], [makeRepo()]) + const automation = makeAutomation(makeRunContext({ projectId: 'repo:repo-1' })) + + const result = resolveAutomationRunTarget(store, automation) + + expect(result).toMatchObject({ ok: true, cwd: '/repo' }) + }) + + it('resolves when the snapshot is at the git: tier and the setup climbed to github:', () => { + const store = makeStore([makeSetup()], [makeRepo()]) + const automation = makeAutomation(makeRunContext({ projectId: 'git:github.com/o/r' })) + + const result = resolveAutomationRunTarget(store, automation) + + expect(result).toMatchObject({ ok: true, cwd: '/repo' }) + }) + + it('still blocks when the repoId no longer matches the setup', () => { + const store = makeStore([makeSetup({ repoId: 'repo-2' })], [makeRepo()]) + const automation = makeAutomation(makeRunContext({ repoId: 'repo-1' })) + + const result = resolveAutomationRunTarget(store, automation) + + expect(result).toMatchObject({ ok: false }) + }) + + it('still blocks when the hostId no longer matches the setup', () => { + // Guard returns at the setup hostId/repoId check before any repo lookup, so + // the repo's execution host is irrelevant here — assert the guard's own error. + const store = makeStore([makeSetup({ hostId: 'ssh:devbox' })], [makeRepo()]) + const automation = makeAutomation(makeRunContext({ hostId: 'local' })) + + const result = resolveAutomationRunTarget(store, automation) + + expect(result).toMatchObject({ + ok: false, + error: 'Automation run target no longer matches the selected project host setup.' + }) + }) + + it("still blocks when the repo's execution host drifted off the automation host", () => { + // Setup/context hostId agree, but the repo itself now executes on another host — + // pins the repo-execution-host check that lives past the setup-match guard. + const store = makeStore([makeSetup()], [makeRepo({ executionHostId: 'ssh:devbox' })]) + const automation = makeAutomation(makeRunContext()) + + const result = resolveAutomationRunTarget(store, automation) + + expect(result).toMatchObject({ + ok: false, + error: 'Repository is no longer attached to the selected automation host.' + }) + }) + + it('still blocks when the path no longer matches the setup', () => { + const store = makeStore([makeSetup({ path: '/repo/new' })], [makeRepo({ path: '/repo/new' })]) + const automation = makeAutomation(makeRunContext({ path: '/repo/old' })) + + const result = resolveAutomationRunTarget(store, automation) + + expect(result).toMatchObject({ + ok: false, + error: 'Project path for the selected automation host has changed.' + }) + }) +}) diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts index 417737a99..5ff447d65 100644 --- a/src/main/automations/run-target-resolution.ts +++ b/src/main/automations/run-target-resolution.ts @@ -64,11 +64,10 @@ export function resolveAutomationRunTarget( error: `Project setup on the selected automation host is ${setup.setupState}.` } } - if ( - setup.projectId !== context.projectId || - setup.hostId !== context.hostId || - setup.repoId !== context.repoId - ) { + // Why: projectId is a derived identity that upgrades over time (repo:→git:→github:); + // matching on it strands automations created before their repo's identity resolved. + // Anchor on repoId/hostId/path instead — the durable, stable target identity. + if (setup.hostId !== context.hostId || setup.repoId !== context.repoId) { return { ok: false, error: 'Automation run target no longer matches the selected project host setup.' diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index cbc9b029c..8c9ab713e 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -140,6 +140,72 @@ describe('automation target availability', () => { ).toBe('host-mismatch') }) + it('allows a run context whose derived projectId tier drifted but repo/host/path still match', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + // Snapshotted at the repo: tier before the setup climbed to github:. + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r' })], + sshConnectionStates: new Map() + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + + it('still blocks a run context whose repoId no longer matches despite projectId drift', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'repo:repo-2', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-2', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r', repoId: 'repo-2' })], + sshConnectionStates: new Map() + }).reason + ).toBe('host-mismatch') + }) + + it('still blocks when the setup repoId drifts even though the live repo still matches', () => { + // Live repo matches runContext.repoId (repoMatchesContext passes), so this + // isolates the setup-side repoId clause that survives the projectId removal. + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r', repoId: 'repo-2' })], + sshConnectionStates: new Map() + }).reason + ).toBe('host-mismatch') + }) + it('allows remote-listed SSH automations whose repo is projected through a runtime server', () => { expect( getAutomationTargetAvailability({ diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index 008b4c9eb..ca7c166b5 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -96,8 +96,10 @@ export function getAutomationTargetAvailability({ `Project setup on the selected automation host is ${setup.setupState}.` ) } + // Why: projectId is a derived identity that upgrades over time (repo:→git:→github:); + // matching on it strands automations created before their repo's identity resolved. + // Anchor on repoId/path/host instead — the durable, stable target identity. const setupMatchesContext = - setup.projectId === automation.runContext.projectId && setup.repoId === automation.runContext.repoId && setup.path === automation.runContext.path && setupHostMatchesRunContext(setup.hostId, automation.runContext.hostId, automationHostTarget)