Run automations after a repo's project identity changes (#7462)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
61bd98db6f
commit
b2d079c80d
|
|
@ -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> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeSetup(overrides: Partial<ProjectHostSetup> = {}): 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> = {}): 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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue