mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Add runtime storage authority support for automations
- Support both runtime and desktop as automation storage authorities - Make owner preconditions optional for legacy-client compatibility - Cache automation list projections to improve performance - Add per-row repo/worktree resolution for cross-authority collisions - Extend automation.list RPC to always include owner metadata
This commit is contained in:
@@ -49,13 +49,8 @@ async function sshTargetGeneration(
|
||||
client: RuntimeClient,
|
||||
targetId: string
|
||||
): Promise<number | undefined> {
|
||||
let targets: SshTargetSummary[]
|
||||
try {
|
||||
targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')).result
|
||||
.targets
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries'))
|
||||
.result.targets
|
||||
const match = targets.find((candidate) => candidate.id === targetId)
|
||||
if (!match) {
|
||||
throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination)
|
||||
|
||||
@@ -23,6 +23,7 @@ function authority(options: {
|
||||
/** Generation the same target carries by the time the write lands. */
|
||||
generationAtWrite?: number
|
||||
repoConnectionId?: string | null
|
||||
targetSummaryError?: Error
|
||||
}): { call: ReturnType<typeof vi.fn>; writes: { method: string; params: unknown }[] } {
|
||||
const writes: { method: string; params: unknown }[] = []
|
||||
const connectionId = options.repoConnectionId ?? null
|
||||
@@ -37,6 +38,9 @@ function authority(options: {
|
||||
return ok({ repo: { id: 'r1', connectionId } })
|
||||
}
|
||||
if (method === 'ssh.listTargetSummaries') {
|
||||
if (options.targetSummaryError) {
|
||||
throw options.targetSummaryError
|
||||
}
|
||||
return ok({ targets: options.registered })
|
||||
}
|
||||
if (method === 'automation.create' || method === 'automation.update') {
|
||||
@@ -109,6 +113,26 @@ describe('CLI automation writes fence the host they land on', () => {
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not drop the fence when reading SSH registrations fails', async () => {
|
||||
const { call, writes } = authority({
|
||||
registered: [],
|
||||
repoConnectionId: 'box-1',
|
||||
targetSummaryError: new Error('rpc timeout')
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await expect(
|
||||
AUTOMATION_HANDLERS['automations create']!({
|
||||
client: { call } as never,
|
||||
cwd: '/tmp',
|
||||
flags: new Map([...CREATE_FLAGS, ['repo', 'repo-on-box-1']]),
|
||||
json: true
|
||||
})
|
||||
).rejects.toThrow('rpc timeout')
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a selector-moving edit when the host is re-registered between capture and write', async () => {
|
||||
const { call } = authority({
|
||||
registered: [{ id: 'box-1', label: 'box 1', generation: 7 }],
|
||||
|
||||
@@ -194,14 +194,11 @@ describe('owner-fenced mutations', () => {
|
||||
expect(store.listAutomations().find((entry) => entry.id === 'ssh-1-a')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an unfenced mutation of a record pinned to a generation', async () => {
|
||||
it('keeps legacy mutations compatible without an owner field', async () => {
|
||||
const store = await createStore()
|
||||
const conflict = expect.objectContaining({
|
||||
code: AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired
|
||||
})
|
||||
expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).toThrowError(conflict)
|
||||
expect(() => store.deleteAutomation('ssh-1-a')).toThrowError(conflict)
|
||||
expect(store.listAutomations()).toHaveLength(3)
|
||||
expect(store.updateAutomation('ssh-1-a', { enabled: false }).enabled).toBe(false)
|
||||
expect(() => store.deleteAutomation('ssh-1-a')).not.toThrow()
|
||||
expect(store.listAutomations()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('leaves a legacy client unaffected on a self record', async () => {
|
||||
@@ -256,6 +253,15 @@ describe('owner-fenced mutations', () => {
|
||||
* The projection is what makes the fence satisfiable off-host rather than a wall.
|
||||
*/
|
||||
describe('projected owner precondition', () => {
|
||||
it('reuses the complete projection until automation state changes', async () => {
|
||||
const store = await createStore()
|
||||
const first = store.listAutomationsForScope()
|
||||
expect(store.listAutomationsForScope()).toBe(first)
|
||||
|
||||
store.updateAutomation('local-1', { enabled: false })
|
||||
expect(store.listAutomationsForScope()).not.toBe(first)
|
||||
})
|
||||
|
||||
it('projects the fenceable owner for each kind of record', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.automationOwnerPrecondition('ssh-1-a')).toEqual(OWNED_SSH)
|
||||
@@ -270,9 +276,7 @@ describe('projected owner precondition', () => {
|
||||
it('satisfies the fence it was projected from, on the record that used to refuse', async () => {
|
||||
const store = await createStore()
|
||||
const owner = store.automationOwnerPrecondition('ssh-1-a')!
|
||||
expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).toThrowError(
|
||||
expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired })
|
||||
)
|
||||
expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).not.toThrow()
|
||||
expect(
|
||||
store.updateAutomation('ssh-1-a', { enabled: false }, { expectedOwner: owner }).enabled
|
||||
).toBe(false)
|
||||
|
||||
@@ -70,6 +70,7 @@ function migrate(input: {
|
||||
folderWorkspaces?: FolderWorkspace[]
|
||||
projectGroups?: ProjectGroup[]
|
||||
sshTargetGenerationCounter?: number
|
||||
storageAuthority?: 'desktop' | 'runtime'
|
||||
}) {
|
||||
return migrateAutomationOwners({
|
||||
automations: input.automations ?? [],
|
||||
@@ -79,6 +80,7 @@ function migrate(input: {
|
||||
projectGroups: input.projectGroups,
|
||||
removedSshTargetTombstones: [],
|
||||
sshTargetGenerationCounter: input.sshTargetGenerationCounter,
|
||||
storageAuthority: input.storageAuthority,
|
||||
now: NOW
|
||||
})
|
||||
}
|
||||
@@ -283,6 +285,38 @@ describe('migrateAutomationOwners', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ label: 'schedulerOwner', overrides: { schedulerOwner: 'remote_host_service' as const } },
|
||||
{
|
||||
label: 'runContext.hostId',
|
||||
overrides: {
|
||||
runContext: {
|
||||
kind: 'workspace-run' as const,
|
||||
projectId: 'p',
|
||||
hostId: 'runtime:env-1' as const,
|
||||
projectHostSetupId: 's',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/repo'
|
||||
}
|
||||
}
|
||||
}
|
||||
])('keeps a runtime-owned record flagged by $label enabled and owned', ({ overrides }) => {
|
||||
const result = migrate({
|
||||
automations: [makeAutomation(overrides)],
|
||||
repos: [makeRepo()],
|
||||
storageAuthority: 'runtime'
|
||||
})
|
||||
const migrated = result.automations[0]
|
||||
expect(migrated.enabled).toBe(true)
|
||||
expect(
|
||||
projectAutomationSelector(migrated, {
|
||||
storageAuthority: 'runtime',
|
||||
sshTargetGeneration: () => undefined,
|
||||
repoConnectionId: () => null
|
||||
})
|
||||
).toEqual({ kind: 'self' })
|
||||
})
|
||||
|
||||
it('leaves a differing captured generation alone so the record stays a replaced orphan', () => {
|
||||
const result = migrate({
|
||||
automations: [
|
||||
|
||||
@@ -31,7 +31,7 @@ import { scanAutomationsForGhostSshTargets } from './automation-ghost-ssh-tombst
|
||||
export type AutomationOwnerClassification = 'owned' | 'orphan' | 'ambiguous'
|
||||
|
||||
/**
|
||||
* Storage authority is never inferred from these fields — they only mark a record unrunnable.
|
||||
* Runtime markers belong to a runtime store; the same markers are ambiguous desktop mirrors.
|
||||
*
|
||||
* `knownSshTargetIds` is the authority's own persisted registry, so a miss is
|
||||
* settled evidence rather than a list that has not loaded. `workspacePin` is
|
||||
@@ -41,11 +41,13 @@ export type AutomationOwnerClassification = 'owned' | 'orphan' | 'ambiguous'
|
||||
export function classifyAutomationOwner(
|
||||
automation: Automation,
|
||||
knownSshTargetIds: ReadonlySet<string>,
|
||||
workspacePin?: AutomationWorkspaceSshPin
|
||||
workspacePin?: AutomationWorkspaceSshPin,
|
||||
storageAuthority: 'desktop' | 'runtime' = 'desktop'
|
||||
): AutomationOwnerClassification {
|
||||
if (
|
||||
automation.schedulerOwner === 'remote_host_service' ||
|
||||
parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime'
|
||||
storageAuthority !== 'runtime' &&
|
||||
(automation.schedulerOwner === 'remote_host_service' ||
|
||||
parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime')
|
||||
) {
|
||||
return 'ambiguous'
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export type AutomationOwnerMigrationInput = {
|
||||
projectGroups?: readonly ProjectGroup[]
|
||||
removedSshTargetTombstones: readonly RemovedSshTargetTombstone[]
|
||||
sshTargetGenerationCounter?: number
|
||||
storageAuthority?: 'desktop' | 'runtime'
|
||||
now: number
|
||||
}
|
||||
|
||||
@@ -176,7 +179,8 @@ function indexTargetsByGeneration(
|
||||
function migrateAutomations(
|
||||
automations: readonly Automation[],
|
||||
targetsById: ReadonlyMap<string, SshTarget>,
|
||||
workspaceState: FolderWorkspaceHostState
|
||||
workspaceState: FolderWorkspaceHostState,
|
||||
storageAuthority: 'desktop' | 'runtime'
|
||||
): MigratedAutomations {
|
||||
const knownIds = new Set(targetsById.keys())
|
||||
const sshTargetIdForGeneration = indexTargetsByGeneration(targetsById)
|
||||
@@ -185,7 +189,7 @@ function migrateAutomations(
|
||||
const pin = resolveWorkspacePin(automation, workspaceState, targetsById)
|
||||
// Before classification: a followed re-pin is a healthy record, not an orphan.
|
||||
const record = followWorkspaceRepin(automation, pin, sshTargetIdForGeneration)
|
||||
const classification = classifyAutomationOwner(record, knownIds, pin)
|
||||
const classification = classifyAutomationOwner(record, knownIds, pin, storageAuthority)
|
||||
// Orphans and ambiguous records must not keep firing on a guessed host, but
|
||||
// they are never moved, rewritten to Self, or deleted.
|
||||
if (classification !== 'owned') {
|
||||
@@ -226,11 +230,16 @@ export function migrateAutomationOwners(
|
||||
})
|
||||
const stamped = stampTargetGenerations(input.sshTargets, highWaterMark)
|
||||
const targetsById = new Map(stamped.targets.map((target) => [target.id, target]))
|
||||
const migrated = migrateAutomations(input.automations, targetsById, {
|
||||
folderWorkspaces: [...(input.folderWorkspaces ?? [])],
|
||||
projectGroups: [...(input.projectGroups ?? [])],
|
||||
repos: [...input.repos]
|
||||
})
|
||||
const migrated = migrateAutomations(
|
||||
input.automations,
|
||||
targetsById,
|
||||
{
|
||||
folderWorkspaces: [...(input.folderWorkspaces ?? [])],
|
||||
projectGroups: [...(input.projectGroups ?? [])],
|
||||
repos: [...input.repos]
|
||||
},
|
||||
input.storageAuthority ?? 'desktop'
|
||||
)
|
||||
return {
|
||||
automations: migrated.automations,
|
||||
sshTargets: stamped.targets,
|
||||
|
||||
+4
-2
@@ -2307,13 +2307,15 @@ void app.whenReady().then(async () => {
|
||||
)
|
||||
|
||||
const activeOrcaProfile = ensureActiveOrcaProfile()
|
||||
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
||||
store = new Store({
|
||||
dataFile: activeOrcaProfile.dataFile,
|
||||
storageAuthority: isServeMode ? 'runtime' : 'desktop'
|
||||
})
|
||||
// Why here and not at install time: the report remembers what it last said, and that
|
||||
// state lives beside the profile data file, which does not exist until now.
|
||||
reportSecretProtectionGap({
|
||||
dataFile: activeOrcaProfile.dataFile,
|
||||
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1'
|
||||
})
|
||||
// Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
|
||||
// it. Left unbound it reports nothing trusted, which is safe but silently discards our own
|
||||
// accept records on every launch.
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SshTarget
|
||||
} from '../../../shared/ssh-types'
|
||||
import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types'
|
||||
import { sanitizeSshTargetGeneration } from '../../../shared/ssh-target-generation'
|
||||
|
||||
export type LegacySshTarget = SshTarget & {
|
||||
remoteWorkspaceSyncEnabled?: unknown
|
||||
@@ -33,6 +34,9 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
|
||||
...target,
|
||||
configHost: target.configHost ?? target.label ?? target.host
|
||||
}
|
||||
if (sanitizeSshTargetGeneration(target.generation) === undefined) {
|
||||
delete normalized.generation
|
||||
}
|
||||
// Why: old SSH form persisted 10800 even without a user choice; treat that legacy default as the new implicit default.
|
||||
if (
|
||||
relayGracePeriodSeconds !== undefined &&
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ProjectHostSetup } from '../../../shared/project-types'
|
||||
import { toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
|
||||
import {
|
||||
migrateFolderWorkspaceHostSshTargetId,
|
||||
migrateUiHostScopeSshTargetId,
|
||||
migrateWorkspaceSessionSshTargetId
|
||||
} from '../../ssh/ssh-target-id-migration'
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
migrateRetirementNamespaceHostIdentity,
|
||||
sshHostIdentity
|
||||
} from '../../worktree-retirement-namespace'
|
||||
import {
|
||||
migrateAutomationHostFilterSshTargetId,
|
||||
migrateAutomationsForSshReadoption
|
||||
} from '../../automations/automation-ssh-readoption-migration'
|
||||
import { automationIdsPinnedToSshTarget } from '../scheduling-automations/automation-owner-projection'
|
||||
|
||||
export type SshTargetReassignmentOperations = {
|
||||
state: StoreOwnedPersistedState
|
||||
@@ -107,6 +113,30 @@ export function reassignSshTargetId(
|
||||
if (migrateRetirementNamespaces(operations.state, oldTargetId, newTargetId)) {
|
||||
carrierChanged = true
|
||||
}
|
||||
const workspacePinnedAutomationIds = automationIdsPinnedToSshTarget(
|
||||
operations.state,
|
||||
oldTargetId
|
||||
)
|
||||
if (migrateFolderWorkspaceHostSshTargetId(operations.state, oldTargetId, newTargetId)) {
|
||||
carrierChanged = true
|
||||
}
|
||||
if (
|
||||
migrateAutomationsForSshReadoption({
|
||||
automations: operations.state.automations ?? [],
|
||||
automationRuns: operations.state.automationRuns ?? [],
|
||||
oldTargetId,
|
||||
newTargetId,
|
||||
workspacePinnedAutomationIds,
|
||||
newTargetGeneration: (operations.state.sshTargets ?? []).find(
|
||||
(target) => target.id === newTargetId
|
||||
)?.generation
|
||||
})
|
||||
) {
|
||||
carrierChanged = true
|
||||
}
|
||||
if (migrateAutomationHostFilterSshTargetId(operations.state.ui, oldTargetId, newTargetId)) {
|
||||
carrierChanged = true
|
||||
}
|
||||
for (const lease of operations.state.sshRemotePtyLeases ?? []) {
|
||||
if (lease.targetId === oldTargetId) {
|
||||
lease.targetId = newTargetId
|
||||
|
||||
@@ -4,9 +4,13 @@ import type { StoreOwnedPersistedState } from '../loading-store/store-owned-stat
|
||||
import type { ProtectedSecretPersistence } from '../../protected-secret-persistence'
|
||||
import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence'
|
||||
import {
|
||||
MAX_CLAUDE_LIVE_PTY_SESSION_IDS,
|
||||
MAX_REMOVED_SSH_TARGET_TOMBSTONES
|
||||
MAX_CLAUDE_LIVE_PTY_SESSION_IDS
|
||||
} from '../restoring-sessions/pane-alias-normalization'
|
||||
import {
|
||||
MAX_REMOVED_SSH_TARGET_TOMBSTONES,
|
||||
capRemovedSshTargetTombstones,
|
||||
collectSshTargetRemovalEvidenceDependencies
|
||||
} from '../../ssh/removed-ssh-target-tombstone-retention'
|
||||
import { normalizeSshTarget } from './ssh-normalization'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
|
||||
import {
|
||||
@@ -171,13 +175,30 @@ export function addRemovedSshTargetTombstone(
|
||||
const existing = operations.state.removedSshTargetTombstones ?? []
|
||||
// Why: dedupe by oldTargetId so re-removing the same id can't stack duplicate tombstones; newest wins.
|
||||
const filtered = existing.filter((entry) => entry.oldTargetId !== tombstone.oldTargetId)
|
||||
// Cap the history so pathological churn can't grow the state file unbounded.
|
||||
operations.state.removedSshTargetTombstones = [...filtered, tombstone].slice(
|
||||
-MAX_REMOVED_SSH_TARGET_TOMBSTONES
|
||||
operations.state.removedSshTargetTombstones = capRemovedSshTargetTombstones(
|
||||
[...filtered, tombstone],
|
||||
sshTargetRemovalEvidenceDependencies(operations.state),
|
||||
MAX_REMOVED_SSH_TARGET_TOMBSTONES
|
||||
)
|
||||
operations.scheduleSave()
|
||||
}
|
||||
|
||||
function sshTargetRemovalEvidenceDependencies(state: PersistedState): Set<string> {
|
||||
return collectSshTargetRemovalEvidenceDependencies({
|
||||
automations: state.automations ?? [],
|
||||
automationHostFilter: state.ui?.automationHostFilter
|
||||
})
|
||||
}
|
||||
|
||||
export function releaseRemovedSshTargetTombstone(
|
||||
operations: SshTargetStateOperations,
|
||||
oldTargetId: string
|
||||
): void {
|
||||
if (!sshTargetRemovalEvidenceDependencies(operations.state).has(oldTargetId)) {
|
||||
removeRemovedSshTargetTombstone(operations, oldTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeRemovedSshTargetTombstone(
|
||||
operations: SshTargetStateOperations,
|
||||
oldTargetId: string
|
||||
|
||||
@@ -27,6 +27,17 @@ import type {
|
||||
AutomationRunTrigger,
|
||||
AutomationUpdateInput
|
||||
} from '../../../shared/automations-types'
|
||||
import type {
|
||||
AutomationCapturedHostIssue,
|
||||
AutomationChangeSelector,
|
||||
AutomationListParams,
|
||||
AutomationListResult
|
||||
} from '../../../shared/automation-list-scope'
|
||||
import type {
|
||||
AutomationDestination,
|
||||
AutomationOwnerFenceOperation,
|
||||
AutomationOwnerPrecondition
|
||||
} from '../../../shared/automation-owner-precondition'
|
||||
import { normalizeProxyUrl } from '../../../shared/network-proxy'
|
||||
import { normalizeKagiSessionLink } from '../../../shared/browser-url'
|
||||
import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types'
|
||||
@@ -258,6 +269,7 @@ import {
|
||||
removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation,
|
||||
removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation,
|
||||
removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation,
|
||||
releaseRemovedSshTargetTombstone as releaseRemovedSshTargetTombstoneOperation,
|
||||
removeSshTarget as removeSshTargetOperation,
|
||||
updateSshTarget as updateSshTargetOperation
|
||||
} from '../leasing-ssh-ptys/ssh-target-state'
|
||||
@@ -331,6 +343,7 @@ import {
|
||||
import {
|
||||
createAutomationRun as createAutomationRunOperation,
|
||||
listAutomationRuns as listAutomationRunsOperation,
|
||||
recordRepeatedAutomationSkip as recordRepeatedAutomationSkipOperation,
|
||||
snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation,
|
||||
updateAutomationRun as updateAutomationRunOperation,
|
||||
type AutomationRunOperations
|
||||
@@ -358,6 +371,17 @@ import {
|
||||
import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration'
|
||||
import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations'
|
||||
import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update'
|
||||
import { migrateAutomationOwners } from '../../automations/automation-owner-migration'
|
||||
import {
|
||||
allocateSshTargetGeneration as allocateSshTargetGenerationOperation,
|
||||
assertAutomationOwnerFence as assertAutomationOwnerFenceOperation,
|
||||
automationCapturedHostIssue as automationCapturedHostIssueOperation,
|
||||
automationChangeSelector as automationChangeSelectorOperation,
|
||||
automationOwnerPrecondition as automationOwnerPreconditionOperation,
|
||||
listAutomationsForScope as listAutomationsForScopeOperation,
|
||||
type AutomationListProjectionCache,
|
||||
type AutomationStorageAuthority
|
||||
} from '../scheduling-automations/automation-owner-projection'
|
||||
|
||||
// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable.
|
||||
const BACKUP_COUNT = 5
|
||||
@@ -498,6 +522,7 @@ function deleteRemovedTerminalScrollbackSnapshots(
|
||||
|
||||
export type StoreOptions = {
|
||||
dataFile?: string
|
||||
storageAuthority?: AutomationStorageAuthority
|
||||
}
|
||||
|
||||
export type PtyBindingSourceExpectation = {
|
||||
@@ -512,6 +537,7 @@ export class Store {
|
||||
// Why readonly: the operations wrappers below capture this reference once and are memoized.
|
||||
private readonly state: PersistedState
|
||||
private readonly dataFile: string
|
||||
private readonly storageAuthority: AutomationStorageAuthority
|
||||
private readonly activeViewPreference: ActiveViewPreference
|
||||
private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -546,10 +572,12 @@ export class Store {
|
||||
) => void
|
||||
>()
|
||||
private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>()
|
||||
private automationListProjectionCache: AutomationListProjectionCache | null = null
|
||||
|
||||
constructor(options: StoreOptions = {}) {
|
||||
// Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path.
|
||||
this.dataFile = options.dataFile ?? getDataFile()
|
||||
this.storageAuthority = options.storageAuthority ?? 'desktop'
|
||||
this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, {
|
||||
minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS
|
||||
})
|
||||
@@ -1605,6 +1633,28 @@ export class Store {
|
||||
automationRuns: automationContextMigration.state.automationRuns
|
||||
}
|
||||
|
||||
const automationOwnerMigration = migrateAutomationOwners({
|
||||
automations: result.automations,
|
||||
sshTargets: result.sshTargets,
|
||||
repos,
|
||||
folderWorkspaces: result.folderWorkspaces,
|
||||
projectGroups: result.projectGroups,
|
||||
removedSshTargetTombstones: result.removedSshTargetTombstones ?? [],
|
||||
sshTargetGenerationCounter: result.sshTargetGenerationCounter,
|
||||
storageAuthority: this.storageAuthority,
|
||||
now: Date.now()
|
||||
})
|
||||
if (automationOwnerMigration.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
automations: automationOwnerMigration.automations,
|
||||
sshTargets: automationOwnerMigration.sshTargets,
|
||||
removedSshTargetTombstones: automationOwnerMigration.removedSshTargetTombstones,
|
||||
sshTargetGenerationCounter: automationOwnerMigration.sshTargetGenerationCounter
|
||||
}
|
||||
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
repos,
|
||||
@@ -1715,6 +1765,7 @@ export class Store {
|
||||
private static SAVE_MAX_WAIT_MS = 5_000
|
||||
|
||||
private scheduleSave(): void {
|
||||
this.automationListProjectionCache = null
|
||||
// Why: once the quit flush has snapshotted, a newly debounced write would fire during
|
||||
// teardown with nothing awaiting it, and the process can exit mid-rename. The quit
|
||||
// flush is the last write by construction.
|
||||
@@ -2403,6 +2454,7 @@ export class Store {
|
||||
private getAutomationDefinitionOperations(): AutomationDefinitionOperations {
|
||||
return {
|
||||
state: this.state,
|
||||
storageAuthority: this.storageAuthority,
|
||||
flush: () => this.flush(),
|
||||
recordCreated: () => this.recordFeatureInteraction('automation-created')
|
||||
}
|
||||
@@ -2422,20 +2474,67 @@ export class Store {
|
||||
return listAutomationsOperation(this.state)
|
||||
}
|
||||
|
||||
listAutomationsForScope(params?: AutomationListParams | null): AutomationListResult {
|
||||
const projection = listAutomationsForScopeOperation({
|
||||
state: this.state,
|
||||
storageAuthority: this.storageAuthority,
|
||||
automations: this.listAutomations(),
|
||||
params,
|
||||
cache: this.automationListProjectionCache
|
||||
})
|
||||
this.automationListProjectionCache = projection.cache
|
||||
return projection.result
|
||||
}
|
||||
|
||||
automationOwnerPrecondition(id: string): AutomationOwnerPrecondition | null {
|
||||
return automationOwnerPreconditionOperation(this.state, this.storageAuthority, id)
|
||||
}
|
||||
|
||||
automationChangeSelector(id: string): AutomationChangeSelector | null {
|
||||
return automationChangeSelectorOperation(this.state, this.storageAuthority, id)
|
||||
}
|
||||
|
||||
automationCapturedHostIssue(automation: Automation): AutomationCapturedHostIssue | null {
|
||||
return automationCapturedHostIssueOperation(this.state, this.storageAuthority, automation)
|
||||
}
|
||||
|
||||
assertAutomationOwnerFence(input: {
|
||||
id: string
|
||||
expectedOwner?: AutomationOwnerPrecondition
|
||||
operation: AutomationOwnerFenceOperation
|
||||
}): Automation {
|
||||
return assertAutomationOwnerFenceOperation({
|
||||
state: this.state,
|
||||
storageAuthority: this.storageAuthority,
|
||||
...input
|
||||
})
|
||||
}
|
||||
|
||||
allocateSshTargetGeneration(): number {
|
||||
return allocateSshTargetGenerationOperation(this.state, () => this.scheduleSave())
|
||||
}
|
||||
|
||||
listAutomationRuns(automationId?: string): AutomationRun[] {
|
||||
return listAutomationRunsOperation(this.state, automationId)
|
||||
}
|
||||
|
||||
createAutomation(input: AutomationCreateInput): Automation {
|
||||
return createAutomationOperation(this.getAutomationDefinitionOperations(), input)
|
||||
createAutomation(
|
||||
input: AutomationCreateInput,
|
||||
options?: { destination?: AutomationDestination }
|
||||
): Automation {
|
||||
return createAutomationOperation(this.getAutomationDefinitionOperations(), input, options)
|
||||
}
|
||||
|
||||
updateAutomation(id: string, updates: AutomationUpdateInput): Automation {
|
||||
return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates)
|
||||
updateAutomation(
|
||||
id: string,
|
||||
updates: AutomationUpdateInput,
|
||||
options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination }
|
||||
): Automation {
|
||||
return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates, options)
|
||||
}
|
||||
|
||||
deleteAutomation(id: string): void {
|
||||
deleteAutomationOperation(this.getAutomationDefinitionOperations(), id)
|
||||
deleteAutomation(id: string, options?: { expectedOwner?: AutomationOwnerPrecondition }): void {
|
||||
deleteAutomationOperation(this.getAutomationDefinitionOperations(), id, options)
|
||||
}
|
||||
|
||||
createAutomationRun(
|
||||
@@ -2451,6 +2550,19 @@ export class Store {
|
||||
)
|
||||
}
|
||||
|
||||
recordRepeatedAutomationSkip(
|
||||
automationId: string,
|
||||
error: string,
|
||||
scheduledFor: number
|
||||
): AutomationRun | null {
|
||||
return recordRepeatedAutomationSkipOperation(
|
||||
this.getAutomationRunOperations(),
|
||||
automationId,
|
||||
error,
|
||||
scheduledFor
|
||||
)
|
||||
}
|
||||
|
||||
updateAutomationRun(result: AutomationDispatchResult): AutomationRun {
|
||||
return updateAutomationRunOperation(this.getAutomationRunOperations(), result)
|
||||
}
|
||||
@@ -3557,6 +3669,10 @@ export class Store {
|
||||
addRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), tombstone)
|
||||
}
|
||||
|
||||
releaseRemovedSshTargetTombstone(oldTargetId: string): void {
|
||||
releaseRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId)
|
||||
}
|
||||
|
||||
removeRemovedSshTargetTombstone(oldTargetId: string): void {
|
||||
removeRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId)
|
||||
}
|
||||
@@ -3686,6 +3802,7 @@ export class Store {
|
||||
// ── Flush (for shutdown) ───────────────────────────────────────────
|
||||
|
||||
flush(): void {
|
||||
this.automationListProjectionCache = null
|
||||
if (this.quitFlushStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,18 @@ import type {
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck'
|
||||
import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules'
|
||||
import {
|
||||
applyAutomationExecutionTarget,
|
||||
deriveAutomationExecutionTargetForCreate,
|
||||
deriveAutomationExecutionTargetForUpdate
|
||||
} from '../../../shared/automation-execution-target'
|
||||
import {
|
||||
assertAutomationDestination,
|
||||
assertAutomationOwnerFence,
|
||||
assertExecutionTargetMatchesDestination,
|
||||
type AutomationDestination,
|
||||
type AutomationOwnerPrecondition
|
||||
} from '../../../shared/automation-owner-precondition'
|
||||
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
|
||||
import {
|
||||
getAutomationContextsForRepo,
|
||||
@@ -14,9 +26,16 @@ import {
|
||||
normalizeAutomationSessionReuse,
|
||||
normalizeAutomationSetupDecisionForWorkspaceMode
|
||||
} from './automation-context-migration'
|
||||
import {
|
||||
automationProjectionContext,
|
||||
automationWorkspaceSshPin,
|
||||
sshTargetGenerationForConnection,
|
||||
type AutomationStorageAuthority
|
||||
} from './automation-owner-projection'
|
||||
|
||||
export type AutomationDefinitionOperations = {
|
||||
state: StoreOwnedPersistedState
|
||||
storageAuthority: AutomationStorageAuthority
|
||||
flush: () => void
|
||||
recordCreated: () => void
|
||||
}
|
||||
@@ -29,11 +48,27 @@ export function listAutomations(state: PersistedState): Automation[] {
|
||||
|
||||
export function createAutomation(
|
||||
operations: AutomationDefinitionOperations,
|
||||
input: AutomationCreateInput
|
||||
input: AutomationCreateInput,
|
||||
options?: { destination?: AutomationDestination }
|
||||
): Automation {
|
||||
const repo = operations.state.repos.find((entry) => entry.id === input.projectId)
|
||||
const now = Date.now()
|
||||
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
|
||||
const workspaceId = input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null
|
||||
const workspaceSshPin = automationWorkspaceSshPin(operations.state, workspaceId)
|
||||
const executionTarget = deriveAutomationExecutionTargetForCreate({
|
||||
repo,
|
||||
sshTargetGeneration: sshTargetGenerationForConnection(operations.state, repo?.connectionId),
|
||||
workspaceSshPin
|
||||
})
|
||||
if (options?.destination) {
|
||||
const context = automationProjectionContext(
|
||||
operations.state,
|
||||
operations.storageAuthority,
|
||||
false
|
||||
)
|
||||
assertAutomationDestination(options.destination, context)
|
||||
assertExecutionTargetMatchesDestination(executionTarget, options.destination, workspaceSshPin)
|
||||
}
|
||||
const schedulerOwner = getAutomationSchedulerOwner(repo)
|
||||
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
|
||||
const automation: Automation = {
|
||||
@@ -45,11 +80,10 @@ export function createAutomation(
|
||||
runContext: input.runContext ?? contexts.runContext,
|
||||
sourceContext: input.sourceContext ?? contexts.sourceContext,
|
||||
projectId: input.projectId,
|
||||
executionTargetType,
|
||||
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
|
||||
...executionTarget,
|
||||
schedulerOwner,
|
||||
workspaceMode: input.workspaceMode,
|
||||
workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null,
|
||||
workspaceId,
|
||||
baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null,
|
||||
setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode(
|
||||
input.workspaceMode,
|
||||
@@ -75,13 +109,28 @@ export function createAutomation(
|
||||
export function updateAutomation(
|
||||
operations: AutomationDefinitionOperations,
|
||||
id: string,
|
||||
updates: AutomationUpdateInput
|
||||
updates: AutomationUpdateInput,
|
||||
options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination }
|
||||
): Automation {
|
||||
const index = (operations.state.automations ?? []).findIndex((entry) => entry.id === id)
|
||||
if (index === -1) {
|
||||
throw new Error('Automation not found.')
|
||||
}
|
||||
const current = operations.state.automations[index]
|
||||
const projectionContext = automationProjectionContext(
|
||||
operations.state,
|
||||
operations.storageAuthority,
|
||||
false
|
||||
)
|
||||
assertAutomationOwnerFence({
|
||||
automation: current,
|
||||
expectedOwner: options?.expectedOwner,
|
||||
operation: 'mutate',
|
||||
context: projectionContext
|
||||
})
|
||||
if (options?.destination) {
|
||||
assertAutomationDestination(options.destination, projectionContext)
|
||||
}
|
||||
// Why: the renderer forwards a Partial verbatim, so `{ enabled: undefined }` survives structuredClone
|
||||
// and would blank the stored value in the spread below. Explicit clears go through the `null` branches.
|
||||
const definedUpdates = Object.fromEntries(
|
||||
@@ -89,14 +138,16 @@ export function updateAutomation(
|
||||
) as AutomationUpdateInput
|
||||
const repoId = updates.projectId ?? current.projectId
|
||||
const repo = operations.state.repos.find((entry) => entry.id === repoId)
|
||||
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
|
||||
const schedulerOwner = getAutomationSchedulerOwner(repo)
|
||||
const selectorMoveRequested =
|
||||
updates.projectId !== undefined || options?.destination !== undefined
|
||||
const schedulerOwner =
|
||||
selectorMoveRequested && repo ? getAutomationSchedulerOwner(repo) : current.schedulerOwner
|
||||
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
|
||||
const rrule = updates.rrule ?? current.rrule
|
||||
const dtstart = updates.dtstart ?? current.dtstart
|
||||
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
|
||||
const workspaceMode = updates.workspaceMode ?? current.workspaceMode
|
||||
const updated: Automation = {
|
||||
const merged: Automation = {
|
||||
...current,
|
||||
...definedUpdates,
|
||||
name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name,
|
||||
@@ -114,8 +165,6 @@ export function updateAutomation(
|
||||
: updates.projectId !== undefined
|
||||
? contexts.sourceContext
|
||||
: (current.sourceContext ?? contexts.sourceContext),
|
||||
executionTargetType,
|
||||
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
|
||||
schedulerOwner,
|
||||
workspaceMode,
|
||||
workspaceId:
|
||||
@@ -148,14 +197,47 @@ export function updateAutomation(
|
||||
nextRunAt: scheduleChanged
|
||||
? nextAutomationOccurrenceAfter(rrule, dtstart, Date.now())
|
||||
: current.nextRunAt,
|
||||
enabledDecidedBy: updates.enabled !== undefined ? 'user' : current.enabledDecidedBy,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
const previousPin = automationWorkspaceSshPin(operations.state, current.workspaceId)
|
||||
const workspaceSshPin = automationWorkspaceSshPin(operations.state, merged.workspaceId)
|
||||
const workspaceSshPinMoved = previousPin?.targetId !== workspaceSshPin?.targetId
|
||||
const executionTarget = deriveAutomationExecutionTargetForUpdate({
|
||||
current,
|
||||
repo,
|
||||
selectorMoveRequested,
|
||||
sshTargetGeneration: sshTargetGenerationForConnection(operations.state, repo?.connectionId),
|
||||
workspaceSshPin,
|
||||
workspaceSshPinMoved
|
||||
})
|
||||
if (options?.destination) {
|
||||
assertExecutionTargetMatchesDestination(executionTarget, options.destination, workspaceSshPin)
|
||||
}
|
||||
const updated = applyAutomationExecutionTarget(
|
||||
merged,
|
||||
executionTarget,
|
||||
workspaceSshPinMoved ? undefined : workspaceSshPin
|
||||
)
|
||||
operations.state.automations[index] = updated
|
||||
operations.flush()
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteAutomation(operations: AutomationDefinitionOperations, id: string): void {
|
||||
export function deleteAutomation(
|
||||
operations: AutomationDefinitionOperations,
|
||||
id: string,
|
||||
options?: { expectedOwner?: AutomationOwnerPrecondition }
|
||||
): void {
|
||||
const automation = (operations.state.automations ?? []).find((entry) => entry.id === id)
|
||||
if (automation) {
|
||||
assertAutomationOwnerFence({
|
||||
automation,
|
||||
expectedOwner: options?.expectedOwner,
|
||||
operation: 'mutate',
|
||||
context: automationProjectionContext(operations.state, operations.storageAuthority, false)
|
||||
})
|
||||
}
|
||||
operations.state.automations = (operations.state.automations ?? []).filter(
|
||||
(entry) => entry.id !== id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Automation, AutomationRun } from '../../../shared/automations-types'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import {
|
||||
automationCapturedHostIssue as resolveAutomationCapturedHostIssue,
|
||||
automationSelectorMatchesScope,
|
||||
projectAutomationList,
|
||||
projectAutomationSelector,
|
||||
toAutomationChangeSelector,
|
||||
type AutomationCapturedHostIssue,
|
||||
type AutomationChangeSelector,
|
||||
type AutomationListParams,
|
||||
type AutomationListResult,
|
||||
type AutomationProjectionContext
|
||||
} from '../../../shared/automation-list-scope'
|
||||
import {
|
||||
assertAutomationOwnerFence as enforceAutomationOwnerFence,
|
||||
toAutomationOwnerPrecondition,
|
||||
type AutomationOwnerFenceOperation,
|
||||
type AutomationOwnerPrecondition
|
||||
} from '../../../shared/automation-owner-precondition'
|
||||
import {
|
||||
AUTOMATION_OWNER_CONFLICT_CODES,
|
||||
AutomationOwnerConflictError
|
||||
} from '../../../shared/automation-owner-conflict'
|
||||
import {
|
||||
nextSshTargetGeneration,
|
||||
resolveSshTargetGenerationHighWaterMark,
|
||||
sanitizeSshTargetGeneration
|
||||
} from '../../../shared/ssh-target-generation'
|
||||
import {
|
||||
resolveAutomationWorkspaceHost,
|
||||
resolveAutomationWorkspaceSshTargetId,
|
||||
type AutomationWorkspaceSshPin
|
||||
} from '../../../shared/automation-workspace-pin'
|
||||
import { summarizeAutomationRunUsage } from '../../../shared/automation-usage-summary'
|
||||
import type { FolderWorkspaceHostState } from '../../../shared/folder-workspace-execution-host'
|
||||
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
|
||||
|
||||
export type AutomationStorageAuthority = 'desktop' | 'runtime'
|
||||
|
||||
export type AutomationListProjectionCache = {
|
||||
inputs: readonly unknown[]
|
||||
result: AutomationListResult
|
||||
}
|
||||
|
||||
export function sshTargetGenerationForConnection(
|
||||
state: PersistedState,
|
||||
connectionId: string | null | undefined
|
||||
): number | undefined {
|
||||
const targetId = connectionId?.trim()
|
||||
const target = targetId ? state.sshTargets?.find((entry) => entry.id === targetId) : undefined
|
||||
return sanitizeSshTargetGeneration(target?.generation)
|
||||
}
|
||||
|
||||
function automationUsageSummaries(state: PersistedState) {
|
||||
const runsByAutomation = new Map<string, AutomationRun[]>()
|
||||
for (const run of state.automationRuns ?? []) {
|
||||
const runs = runsByAutomation.get(run.automationId)
|
||||
if (runs) {
|
||||
runs.push(run)
|
||||
} else {
|
||||
runsByAutomation.set(run.automationId, [run])
|
||||
}
|
||||
}
|
||||
return new Map([...runsByAutomation].map(([id, runs]) => [id, summarizeAutomationRunUsage(runs)]))
|
||||
}
|
||||
|
||||
function folderWorkspaceHostState(state: PersistedState): FolderWorkspaceHostState {
|
||||
return {
|
||||
folderWorkspaces: state.folderWorkspaces ?? [],
|
||||
projectGroups: state.projectGroups ?? [],
|
||||
repos: state.repos ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export function automationIdsPinnedToSshTarget(
|
||||
state: PersistedState,
|
||||
targetId: string
|
||||
): Set<string> {
|
||||
const hostState = folderWorkspaceHostState(state)
|
||||
return new Set(
|
||||
(state.automations ?? [])
|
||||
.filter(
|
||||
(automation) =>
|
||||
resolveAutomationWorkspaceSshTargetId(hostState, automation.workspaceId) === targetId
|
||||
)
|
||||
.map((automation) => automation.id)
|
||||
)
|
||||
}
|
||||
|
||||
export function automationWorkspaceSshPin(
|
||||
state: PersistedState,
|
||||
workspaceId: Automation['workspaceId']
|
||||
): AutomationWorkspaceSshPin | undefined {
|
||||
const targetId = resolveAutomationWorkspaceSshTargetId(
|
||||
folderWorkspaceHostState(state),
|
||||
workspaceId
|
||||
)
|
||||
return targetId === undefined
|
||||
? undefined
|
||||
: {
|
||||
targetId,
|
||||
generation: sshTargetGenerationForConnection(state, targetId)
|
||||
}
|
||||
}
|
||||
|
||||
export function automationProjectionContext(
|
||||
state: PersistedState,
|
||||
storageAuthority: AutomationStorageAuthority,
|
||||
withUsage: boolean
|
||||
): AutomationProjectionContext {
|
||||
const usage = withUsage ? automationUsageSummaries(state) : null
|
||||
const reposById = new Map(state.repos.map((repo) => [repo.id, repo]))
|
||||
const targetIdByGeneration = new Map(
|
||||
(state.sshTargets ?? []).flatMap((target) =>
|
||||
target.generation === undefined ? [] : [[target.generation, target.id] as const]
|
||||
)
|
||||
)
|
||||
return {
|
||||
storageAuthority,
|
||||
sshTargetGeneration: (targetId) => sshTargetGenerationForConnection(state, targetId),
|
||||
sshTargetIdForGeneration: (generation) => targetIdByGeneration.get(generation),
|
||||
repoConnectionId: (repoId) => {
|
||||
const repo = reposById.get(repoId)
|
||||
return repo ? repo.connectionId?.trim() || null : undefined
|
||||
},
|
||||
workspaceHost: (automation) =>
|
||||
resolveAutomationWorkspaceHost(folderWorkspaceHostState(state), automation.workspaceId),
|
||||
...(usage ? { usageSummary: (id: string) => usage.get(id) ?? null } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function projectionInputs(state: PersistedState): readonly unknown[] {
|
||||
return [
|
||||
state.automations,
|
||||
state.automationRuns,
|
||||
state.repos,
|
||||
state.sshTargets,
|
||||
state.folderWorkspaces,
|
||||
state.projectGroups
|
||||
]
|
||||
}
|
||||
|
||||
export function listAutomationsForScope(input: {
|
||||
state: PersistedState
|
||||
storageAuthority: AutomationStorageAuthority
|
||||
automations: readonly Automation[]
|
||||
params?: AutomationListParams | null
|
||||
cache: AutomationListProjectionCache | null
|
||||
}): { result: AutomationListResult; cache: AutomationListProjectionCache } {
|
||||
const scope = input.params?.selector
|
||||
if (scope?.kind === 'ssh') {
|
||||
const current = sshTargetGenerationForConnection(input.state, scope.targetId)
|
||||
if (current === undefined) {
|
||||
throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved)
|
||||
}
|
||||
if (current !== scope.expectedTargetGeneration) {
|
||||
throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
|
||||
}
|
||||
}
|
||||
const inputs = projectionInputs(input.state)
|
||||
const complete =
|
||||
input.cache && inputs.every((entry, index) => entry === input.cache?.inputs[index])
|
||||
? input.cache
|
||||
: {
|
||||
inputs,
|
||||
result: projectAutomationList(
|
||||
input.automations,
|
||||
automationProjectionContext(input.state, input.storageAuthority, true)
|
||||
)
|
||||
}
|
||||
if (!scope) {
|
||||
return { result: complete.result, cache: complete }
|
||||
}
|
||||
const items = complete.result.items.filter((item) =>
|
||||
automationSelectorMatchesScope(item.selector, scope)
|
||||
)
|
||||
const ids = new Set(items.map((item) => item.automationId))
|
||||
return {
|
||||
result: {
|
||||
automations: complete.result.automations.filter((automation) => ids.has(automation.id)),
|
||||
items,
|
||||
orphanCount: complete.result.orphanCount
|
||||
},
|
||||
cache: complete
|
||||
}
|
||||
}
|
||||
|
||||
export function automationOwnerPrecondition(
|
||||
state: PersistedState,
|
||||
storageAuthority: AutomationStorageAuthority,
|
||||
id: string
|
||||
): AutomationOwnerPrecondition | null {
|
||||
const automation = (state.automations ?? []).find((entry) => entry.id === id)
|
||||
return automation
|
||||
? toAutomationOwnerPrecondition(
|
||||
projectAutomationSelector(
|
||||
automation,
|
||||
automationProjectionContext(state, storageAuthority, false)
|
||||
)
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
export function automationChangeSelector(
|
||||
state: PersistedState,
|
||||
storageAuthority: AutomationStorageAuthority,
|
||||
id: string
|
||||
): AutomationChangeSelector | null {
|
||||
const automation = (state.automations ?? []).find((entry) => entry.id === id)
|
||||
return automation
|
||||
? toAutomationChangeSelector(
|
||||
projectAutomationSelector(
|
||||
automation,
|
||||
automationProjectionContext(state, storageAuthority, false)
|
||||
)
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
export function automationCapturedHostIssue(
|
||||
state: PersistedState,
|
||||
storageAuthority: AutomationStorageAuthority,
|
||||
automation: Automation
|
||||
): AutomationCapturedHostIssue | null {
|
||||
return resolveAutomationCapturedHostIssue(
|
||||
automation,
|
||||
automationProjectionContext(state, storageAuthority, false)
|
||||
)
|
||||
}
|
||||
|
||||
export function assertAutomationOwnerFence(input: {
|
||||
state: PersistedState
|
||||
storageAuthority: AutomationStorageAuthority
|
||||
id: string
|
||||
expectedOwner?: AutomationOwnerPrecondition
|
||||
operation: AutomationOwnerFenceOperation
|
||||
}): Automation {
|
||||
const automation = (input.state.automations ?? []).find((entry) => entry.id === input.id)
|
||||
if (!automation) {
|
||||
throw new Error('Automation not found.')
|
||||
}
|
||||
enforceAutomationOwnerFence({
|
||||
automation,
|
||||
expectedOwner: input.expectedOwner,
|
||||
operation: input.operation,
|
||||
context: automationProjectionContext(input.state, input.storageAuthority, false)
|
||||
})
|
||||
return automation
|
||||
}
|
||||
|
||||
export function allocateSshTargetGeneration(
|
||||
state: StoreOwnedPersistedState,
|
||||
scheduleSave: () => void
|
||||
): number {
|
||||
const next = nextSshTargetGeneration(
|
||||
resolveSshTargetGenerationHighWaterMark({
|
||||
persistedCounter: state.sshTargetGenerationCounter,
|
||||
targetGenerations: (state.sshTargets ?? []).map((target) => target.generation),
|
||||
capturedGenerations: (state.automations ?? []).map(
|
||||
(automation) => automation.executionTargetGeneration
|
||||
)
|
||||
})
|
||||
)
|
||||
state.sshTargetGenerationCounter = next
|
||||
scheduleSave()
|
||||
return next
|
||||
}
|
||||
@@ -89,6 +89,46 @@ export function createAutomationRun(
|
||||
return run
|
||||
}
|
||||
|
||||
export function recordRepeatedAutomationSkip(
|
||||
operations: AutomationRunOperations,
|
||||
automationId: string,
|
||||
error: string,
|
||||
scheduledFor: number
|
||||
): AutomationRun | null {
|
||||
const runs = operations.state.automationRuns ?? []
|
||||
const latest = runs
|
||||
.filter((run) => run.automationId === automationId)
|
||||
.reduce<AutomationRun | null>(
|
||||
(newest, run) => (!newest || run.createdAt > newest.createdAt ? run : newest),
|
||||
null
|
||||
)
|
||||
if (
|
||||
!latest ||
|
||||
latest.status !== 'skipped_unavailable' ||
|
||||
latest.trigger !== 'scheduled' ||
|
||||
latest.error !== error
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if ((latest.lastOccurrenceAt ?? latest.scheduledFor) === scheduledFor) {
|
||||
return latest
|
||||
}
|
||||
const now = Date.now()
|
||||
const updated: AutomationRun = {
|
||||
...latest,
|
||||
occurrenceCount: (latest.occurrenceCount ?? 1) + 1,
|
||||
lastOccurrenceAt: scheduledFor
|
||||
}
|
||||
runs[runs.findIndex((run) => run.id === latest.id)] = updated
|
||||
const automation = operations.state.automations.find((entry) => entry.id === automationId)
|
||||
if (automation) {
|
||||
automation.lastRunAt = now
|
||||
automation.updatedAt = now
|
||||
}
|
||||
operations.flush()
|
||||
return updated
|
||||
}
|
||||
|
||||
export function updateAutomationRun(
|
||||
operations: AutomationRunOperations,
|
||||
result: AutomationDispatchResult
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The runtime end of the list contract: an old client that sends no params must
|
||||
* keep receiving the authority's complete list in the legacy shape, and every
|
||||
* owner precondition must survive schema parsing rather than being stripped.
|
||||
* keep receiving the authority's complete list through the legacy field while
|
||||
* current callers also receive owner metadata.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcContext, RpcRequest } from '../core'
|
||||
@@ -20,11 +20,22 @@ function method(name: string) {
|
||||
function runtimeStub() {
|
||||
return {
|
||||
listAutomations: vi.fn(() => [{ id: 'a1' }, { id: 'a2' }]),
|
||||
listAutomationsForScope: vi.fn(() => ({
|
||||
automations: [{ id: 'a1' }],
|
||||
items: [{ automationId: 'a1', selector: { kind: 'self' } }],
|
||||
orphanCount: 2
|
||||
})),
|
||||
listAutomationsForScope: vi.fn((params?: { selector?: { kind: string } }) =>
|
||||
params?.selector
|
||||
? {
|
||||
automations: [{ id: 'a1' }],
|
||||
items: [{ automationId: 'a1', selector: { kind: 'self' } }],
|
||||
orphanCount: 2
|
||||
}
|
||||
: {
|
||||
automations: [{ id: 'a1' }, { id: 'a2' }],
|
||||
items: [
|
||||
{ automationId: 'a1', selector: { kind: 'self' } },
|
||||
{ automationId: 'a2', selector: { kind: 'orphan', issue: 'missing' } }
|
||||
],
|
||||
orphanCount: 1
|
||||
}
|
||||
),
|
||||
listAutomationRuns: vi.fn(() => []),
|
||||
showAutomation: vi.fn(() => ({ id: 'a1' })),
|
||||
automationOwnerPrecondition: vi.fn(() => SSH_OWNER),
|
||||
@@ -49,18 +60,20 @@ async function invoke(name: string, params: unknown, runtime: ReturnType<typeof
|
||||
const SSH_OWNER = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }
|
||||
|
||||
describe('automation.list', () => {
|
||||
it('answers a parameterless request with the complete legacy payload', async () => {
|
||||
it('answers a parameterless request with owner metadata and the legacy field', async () => {
|
||||
const runtime = runtimeStub()
|
||||
expect(await invoke('automation.list', undefined, runtime)).toEqual({
|
||||
automations: [{ id: 'a1' }, { id: 'a2' }]
|
||||
expect(await invoke('automation.list', undefined, runtime)).toMatchObject({
|
||||
automations: [{ id: 'a1' }, { id: 'a2' }],
|
||||
items: [{ automationId: 'a1' }, { automationId: 'a2' }]
|
||||
})
|
||||
expect(runtime.listAutomationsForScope).not.toHaveBeenCalled()
|
||||
expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it('treats an empty object the same as no params', async () => {
|
||||
const runtime = runtimeStub()
|
||||
expect(await invoke('automation.list', {}, runtime)).toEqual({
|
||||
automations: [{ id: 'a1' }, { id: 'a2' }]
|
||||
expect(await invoke('automation.list', {}, runtime)).toMatchObject({
|
||||
automations: [{ id: 'a1' }, { id: 'a2' }],
|
||||
items: [{ automationId: 'a1' }, { automationId: 'a2' }]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +115,7 @@ describe('automation.list from a client that sends literal null params', () => {
|
||||
const response = await dispatcher.dispatch(request)
|
||||
|
||||
expect(response).toMatchObject({ result: { automations: [{ id: 'a1' }, { id: 'a2' }] } })
|
||||
expect(runtime.listAutomationsForScope).not.toHaveBeenCalled()
|
||||
expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ describe('automation RPC methods', () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listAutomations: vi.fn().mockReturnValue([{ id: 'auto-1', name: 'Daily review' }]),
|
||||
listAutomationsForScope: vi.fn().mockReturnValue({
|
||||
automations: [{ id: 'auto-1', name: 'Daily review' }],
|
||||
items: [{ automationId: 'auto-1', selector: { kind: 'self' } }]
|
||||
}),
|
||||
showAutomation: vi.fn().mockReturnValue({ id: 'auto-1', name: 'Daily review' }),
|
||||
createAutomation: vi.fn().mockResolvedValue({ id: 'auto-2', name: 'New review' }),
|
||||
updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', name: 'Paused' }),
|
||||
@@ -70,7 +74,7 @@ describe('automation RPC methods', () => {
|
||||
await dispatcher.dispatch(makeRequest('automation.runNow', { id: 'auto-1' }))
|
||||
await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' }))
|
||||
|
||||
expect(runtime.listAutomations).toHaveBeenCalled()
|
||||
expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({})
|
||||
// A legacy client sends no precondition, so each call forwards an absent expected owner.
|
||||
expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1', undefined)
|
||||
expect(runtime.createAutomation).toHaveBeenCalledWith(
|
||||
|
||||
@@ -11,11 +11,8 @@ export const AUTOMATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'automation.list',
|
||||
params: AutomationList,
|
||||
// Why: a scoped caller gets the qualified projection; a parameterless one keeps the legacy payload.
|
||||
handler: (params, { runtime }) =>
|
||||
params.selector
|
||||
? runtime.listAutomationsForScope({ selector: params.selector })
|
||||
: { automations: runtime.listAutomations() }
|
||||
// The projection retains `automations`, so old clients ignore the added owner metadata.
|
||||
handler: (params, { runtime }) => runtime.listAutomationsForScope(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'automation.show',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AutomationCreateDestinationField } from './AutomationCreateDestinationF
|
||||
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
|
||||
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
|
||||
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
@@ -77,6 +77,7 @@ async function render(options: {
|
||||
searchActive={options.searchActive ?? false}
|
||||
selectedRowKey={null}
|
||||
isSelectedLocal
|
||||
lastRunByAutomationId={new Map()}
|
||||
relativeNow={0}
|
||||
repoMap={new Map()}
|
||||
worktreeMap={new Map()}
|
||||
|
||||
@@ -60,6 +60,8 @@ export type AutomationListLocalRowsProps = {
|
||||
relativeNow: number
|
||||
repoMap: ReadonlyMap<string, Repo>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
repoForRow?: (row: AutomationListRow) => Repo | undefined
|
||||
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
|
||||
runtimeStatusByEnvironmentId: ReadonlyMap<
|
||||
@@ -77,6 +79,8 @@ export type AutomationListLocalRowsProps = {
|
||||
onDelete: (row: AutomationListRow) => void
|
||||
}
|
||||
|
||||
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
|
||||
if (!summary || summary.unavailableRuns > 0) {
|
||||
return summary?.knownRuns
|
||||
@@ -110,12 +114,14 @@ export function AutomationListLocalRows({
|
||||
relativeNow,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
repoForRow,
|
||||
worktreeForRow,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
hostTargetFor,
|
||||
automationSourceHostAvailabilityByRowKey,
|
||||
hostLabelById = new Map(),
|
||||
hostLabelById = EMPTY_HOST_LABELS,
|
||||
isActionEnabled,
|
||||
onSelect,
|
||||
onRunNow,
|
||||
@@ -129,9 +135,9 @@ export function AutomationListLocalRows({
|
||||
<>
|
||||
{rows.map((row) => {
|
||||
const { automation } = row
|
||||
const automationRepo = repoMap.get(getAutomationRunRepoId(automation))
|
||||
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
|
||||
const automationWorktree = automation.workspaceId
|
||||
? worktreeMap.get(automation.workspaceId)
|
||||
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
|
||||
: null
|
||||
const automationRunAvailability = getAutomationTargetAvailability({
|
||||
automation,
|
||||
|
||||
@@ -73,6 +73,8 @@ type AutomationsListPanelProps = {
|
||||
relativeNow: number
|
||||
repoMap: ReadonlyMap<string, Repo>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
repoForRow?: (row: AutomationListRow) => Repo | undefined
|
||||
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
|
||||
runtimeStatusByEnvironmentId: ReadonlyMap<
|
||||
@@ -129,6 +131,8 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
relativeNow,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
repoForRow,
|
||||
worktreeForRow,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
@@ -222,6 +226,8 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
relativeNow,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
repoForRow,
|
||||
worktreeForRow,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
|
||||
import {
|
||||
AUTOMATION_OWNER_CONFLICT_CODES,
|
||||
automationOwnerConflictMessage
|
||||
@@ -33,6 +32,7 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov
|
||||
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
buildAutomationRrule,
|
||||
isValidAutomationCronSchedule,
|
||||
@@ -98,7 +98,12 @@ import {
|
||||
import { buildExternalAutomationListEntries } from './external-automation-list-entries'
|
||||
import type { ExternalAutomationScope } from './external-automation-scope-client'
|
||||
import { useAutomationListSearch } from './use-automation-list-search'
|
||||
import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity'
|
||||
import {
|
||||
automationRepoForRow,
|
||||
automationWorktreeForRow,
|
||||
unscopedAutomationListRows,
|
||||
type AutomationListRow
|
||||
} from './automation-list-row-identity'
|
||||
import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs'
|
||||
import { AutomationsListPanel } from './AutomationsListPanel'
|
||||
import { AutomationsDetailPane } from './AutomationsDetailPane'
|
||||
@@ -192,6 +197,19 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation)
|
||||
const repoMap = useRepoMap()
|
||||
const worktreeMap = useWorktreeMap()
|
||||
const repoForRow = useCallback(
|
||||
(row: AutomationListRow): Repo | undefined => automationRepoForRow(row, repos, repoMap),
|
||||
[repoMap, repos]
|
||||
)
|
||||
const worktreeForRow = useCallback(
|
||||
(
|
||||
row: AutomationListRow,
|
||||
repo: Repo | undefined,
|
||||
workspaceId: string | null | undefined = row.automation.workspaceId
|
||||
): Worktree | undefined =>
|
||||
automationWorktreeForRow(row, worktreesByRepo, repo, worktreeMap, workspaceId),
|
||||
[worktreeMap, worktreesByRepo]
|
||||
)
|
||||
const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents)
|
||||
const defaultAgent =
|
||||
settings?.defaultTuiAgent &&
|
||||
@@ -435,13 +453,15 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
return run
|
||||
}
|
||||
const displayName =
|
||||
worktreeMap.get(run.workspaceId)?.displayName ??
|
||||
(selectedRow
|
||||
? worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId)?.displayName
|
||||
: worktreeMap.get(run.workspaceId)?.displayName) ??
|
||||
workspaceNameCacheRef.current.get(run.workspaceId) ??
|
||||
getWorktreePathBasenameFromId(run.workspaceId)
|
||||
const trimmedDisplayName = displayName?.trim()
|
||||
return trimmedDisplayName ? { ...run, workspaceDisplayName: trimmedDisplayName } : run
|
||||
}),
|
||||
[selectedAutomationRuns.runs, worktreeMap]
|
||||
[repoForRow, selectedAutomationRuns.runs, selectedRow, worktreeForRow, worktreeMap]
|
||||
)
|
||||
const getDraftSetupDecisionDefault = useCallback(
|
||||
(
|
||||
@@ -536,6 +556,18 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const selectedAutomationRunPage = selectedAutomationRunPageId
|
||||
? (selectedRuns.find((run) => run.id === selectedAutomationRunPageId) ?? null)
|
||||
: null
|
||||
const selectedRunWorktreeMap = useMemo(() => {
|
||||
if (!selectedRow) {
|
||||
return worktreeMap
|
||||
}
|
||||
const repo = repoForRow(selectedRow)
|
||||
return new Map(
|
||||
selectedRuns.flatMap((run) => {
|
||||
const worktree = worktreeForRow(selectedRow, repo, run.workspaceId)
|
||||
return worktree ? [[worktree.id, worktree] as const] : []
|
||||
})
|
||||
)
|
||||
}, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap])
|
||||
const worktrees = useMemo(
|
||||
() => worktreesByRepo[draft.projectId] ?? [],
|
||||
[draft.projectId, worktreesByRepo]
|
||||
@@ -764,7 +796,13 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
return ids
|
||||
}, [unifiedTabsByWorktree])
|
||||
const selectedAutomationRunPageWorktree = selectedAutomationRunPage?.workspaceId
|
||||
? (worktreeMap.get(selectedAutomationRunPage.workspaceId) ?? null)
|
||||
? selectedRow
|
||||
? (worktreeForRow(
|
||||
selectedRow,
|
||||
repoForRow(selectedRow),
|
||||
selectedAutomationRunPage.workspaceId
|
||||
) ?? null)
|
||||
: (worktreeMap.get(selectedAutomationRunPage.workspaceId) ?? null)
|
||||
: null
|
||||
const selectedAutomationRunPageWorkspaceDisplay = selectedAutomationRunPage
|
||||
? getAutomationRunWorkspaceDisplay({
|
||||
@@ -802,9 +840,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const isSelectedAutomationRunPageRerunPending =
|
||||
selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id)
|
||||
const automationSourceHostAvailabilityByRowKey = useAutomationSourceHostAvailability(visibleRows)
|
||||
const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null
|
||||
const selectedRepo = selectedRow ? (repoForRow(selectedRow) ?? null) : null
|
||||
const selectedWorktree =
|
||||
selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null
|
||||
selectedRow && selected?.workspaceId
|
||||
? (worktreeForRow(selectedRow, selectedRepo ?? undefined) ?? null)
|
||||
: null
|
||||
const selectedRunNowAvailability = selectedRow
|
||||
? getAutomationTargetAvailability({
|
||||
automation: selectedRow.automation,
|
||||
@@ -1498,7 +1538,13 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
})
|
||||
setDraft((current) => ({ ...current, name: '', prompt: '' }))
|
||||
await refresh()
|
||||
selectAutomationId(automation.id)
|
||||
if (editingAutomationId && editingRowKey) {
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setSelectedRowKey(editingRowKey)
|
||||
setSelectedId(automation.id)
|
||||
} else {
|
||||
selectAutomationId(automation.id)
|
||||
}
|
||||
setCreateOpen(false)
|
||||
if (!editingAutomationId) {
|
||||
useAppStore.getState().recordFeatureInteraction('automation-created')
|
||||
@@ -1616,9 +1662,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
const runNow = async (row: AutomationListRow): Promise<void> => {
|
||||
const automation = row.automation
|
||||
const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null
|
||||
const repo = repoForRow(row) ?? null
|
||||
const workspace = automation.workspaceId
|
||||
? (worktreeMap.get(automation.workspaceId) ?? null)
|
||||
? (worktreeForRow(row, repo ?? undefined) ?? null)
|
||||
: null
|
||||
const rowHostTarget = automationHostTargetFor(row)
|
||||
const availability = getAutomationTargetAvailability({
|
||||
@@ -1819,7 +1865,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const openRunWorkspace = (run: AutomationRun): void => {
|
||||
const runWorktree = run.workspaceId ? (worktreeMap.get(run.workspaceId) ?? null) : null
|
||||
const runWorktree =
|
||||
run.workspaceId && selectedRow
|
||||
? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null)
|
||||
: null
|
||||
const store = useAppStore.getState()
|
||||
const openTabId = getAutomationRunOpenTabId(run)
|
||||
const terminalTabExists = openTabId ? Boolean(store.getTab(openTabId)) : false
|
||||
@@ -2078,7 +2127,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
selectedAutomationRunPageViewState={selectedAutomationRunPageViewState}
|
||||
canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage}
|
||||
isSelectedAutomationRunPageRerunPending={isSelectedAutomationRunPageRerunPending}
|
||||
worktreeMap={worktreeMap}
|
||||
worktreeMap={selectedRunWorktreeMap}
|
||||
fetchExternalAutomationRuns={fetchExternalAutomationRuns}
|
||||
onActivePaneTabChange={setActivePaneTab}
|
||||
onClearExternalRunPage={() => setSelectedExternalRunPage(null)}
|
||||
@@ -2130,6 +2179,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
relativeNow={relativeNow}
|
||||
repoMap={repoMap}
|
||||
worktreeMap={worktreeMap}
|
||||
repoForRow={repoForRow}
|
||||
worktreeForRow={worktreeForRow}
|
||||
projectHostSetups={projectHostSetups}
|
||||
sshConnectionStates={sshConnectionStates}
|
||||
runtimeStatusByEnvironmentId={runtimeStatusByEnvironmentId}
|
||||
|
||||
@@ -139,6 +139,7 @@ export function resolveAutomationHostListRows(
|
||||
const listRow: AutomationListRow = {
|
||||
key: automationListRowKey(entry.stableKey, row.automation.id),
|
||||
automation: row.automation,
|
||||
catalogRef: entry.stableRef,
|
||||
hostLabel: entry.label,
|
||||
usageSummary: row.usageSummary ?? null
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from './automation-host-catalog-types'
|
||||
import type { AutomationHostFilterResolution } from './automation-host-filter-resolution'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { resolveAutomationHostListRows } from './automation-host-list-rows'
|
||||
import {
|
||||
buildAutomationListSearchRows,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
matchAutomationListSearchRowKeys
|
||||
} from './automation-list-search-rows'
|
||||
import { REPO_ID } from './automations-page-fixtures'
|
||||
import { automationRepoForRow, automationWorktreeForRow } from './automation-list-row-identity'
|
||||
|
||||
const repoMap = new Map([
|
||||
[REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo]
|
||||
@@ -136,4 +138,43 @@ describe('automation list row identity across hosts', () => {
|
||||
expect(namesMatching('web-01')).toEqual(['Nightly web-01'])
|
||||
expect(namesMatching('this computer')).toEqual(['Nightly desktop'])
|
||||
})
|
||||
|
||||
it('resolves colliding projects and worktrees inside each row authority', () => {
|
||||
const rows = collidedListRows().rows
|
||||
const repos = [
|
||||
{ ...repoMap.get(REPO_ID)!, displayName: 'Desktop repo', executionHostId: 'local' as const },
|
||||
{
|
||||
...repoMap.get(REPO_ID)!,
|
||||
displayName: 'Runtime repo',
|
||||
executionHostId: 'runtime:gpu' as const
|
||||
}
|
||||
]
|
||||
const worktrees = [
|
||||
{ id: 'ws-1', repoId: REPO_ID, hostId: 'local', displayName: 'Desktop workspace' },
|
||||
{
|
||||
id: 'ws-1',
|
||||
repoId: REPO_ID,
|
||||
hostId: 'runtime:gpu',
|
||||
displayName: 'Runtime workspace'
|
||||
}
|
||||
] as Worktree[]
|
||||
const fallbackRepos = new Map([[REPO_ID, repos[1]]])
|
||||
const fallbackWorktrees = new Map([['ws-1', worktrees[1]]])
|
||||
|
||||
expect(rows.map((row) => automationRepoForRow(row, repos, fallbackRepos)?.displayName)).toEqual(
|
||||
['Desktop repo', 'Runtime repo']
|
||||
)
|
||||
expect(
|
||||
rows.map((row) => {
|
||||
const repo = automationRepoForRow(row, repos, fallbackRepos)
|
||||
return automationWorktreeForRow(
|
||||
row,
|
||||
{ [REPO_ID]: worktrees },
|
||||
repo,
|
||||
fallbackWorktrees,
|
||||
'ws-1'
|
||||
)?.displayName
|
||||
})
|
||||
).toEqual(['Desktop workspace', 'Runtime workspace'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,11 +16,21 @@
|
||||
|
||||
import type { Automation } from '../../../../shared/automations-types'
|
||||
import type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary'
|
||||
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
|
||||
import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getWorktreeExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
export type AutomationListRow = {
|
||||
/** Authority-qualified and incarnation-free; see the module note. */
|
||||
key: string
|
||||
automation: Automation
|
||||
/** Storage authority captured with the host query; absent only for the bootstrap list. */
|
||||
catalogRef?: StableAutomationCatalogRef | null
|
||||
/** The host this row actually came from; empty for unscoped legacy rows. */
|
||||
hostLabel: string
|
||||
/** What the owning authority reported; null means unknown, never zero. */
|
||||
@@ -54,7 +64,48 @@ export function unscopedAutomationListRows(
|
||||
return automations.map((automation) => ({
|
||||
key: automationListRowKey(null, automation.id),
|
||||
automation,
|
||||
catalogRef: null,
|
||||
hostLabel: '',
|
||||
usageSummary: null
|
||||
}))
|
||||
}
|
||||
|
||||
export function automationRepoForRow(
|
||||
row: AutomationListRow,
|
||||
repos: readonly Repo[],
|
||||
fallback: ReadonlyMap<string, Repo>
|
||||
): Repo | undefined {
|
||||
const repoId = getAutomationRunRepoId(row.automation)
|
||||
const authority = row.catalogRef?.authority
|
||||
if (!authority) {
|
||||
return fallback.get(repoId)
|
||||
}
|
||||
return repos.find((repo) => {
|
||||
const host = getRepoExecutionHostId(repo)
|
||||
return (
|
||||
repo.id === repoId &&
|
||||
(authority.kind === 'runtime'
|
||||
? host === `runtime:${encodeURIComponent(authority.environmentId)}`
|
||||
: !host.startsWith('runtime:'))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function automationWorktreeForRow(
|
||||
row: AutomationListRow,
|
||||
worktreesByRepo: Readonly<Record<string, readonly Worktree[]>>,
|
||||
repo: Repo | undefined,
|
||||
fallback: ReadonlyMap<string, Worktree>,
|
||||
workspaceId: string | null | undefined = row.automation.workspaceId
|
||||
): Worktree | undefined {
|
||||
if (!workspaceId || !row.catalogRef || !repo) {
|
||||
return workspaceId ? fallback.get(workspaceId) : undefined
|
||||
}
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
return Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
.find(
|
||||
(worktree) =>
|
||||
worktree.id === workspaceId && getWorktreeExecutionHostId(worktree, repo) === hostId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,15 @@ function makeExternalEntry(
|
||||
runs: [],
|
||||
...overrides
|
||||
}
|
||||
return { key: `${manager.id}:${job.id}`, manager, job }
|
||||
return {
|
||||
key: `${manager.id}:${job.id}`,
|
||||
scope: {
|
||||
owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } },
|
||||
provider: 'hermes'
|
||||
},
|
||||
manager,
|
||||
job
|
||||
}
|
||||
}
|
||||
|
||||
describe('automation-list-view', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type {
|
||||
AutomationHostCatalog,
|
||||
AutomationHostCatalogEntry
|
||||
|
||||
+20
-10
@@ -16,6 +16,7 @@ import {
|
||||
type RepoBackedAutomationSourceContext
|
||||
} from './automation-source-context'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import { createAutomationHostRequestPool } from './automation-host-scheduler-queue'
|
||||
|
||||
/**
|
||||
* Availability of each automation's *source* host — the host its task provider
|
||||
@@ -43,6 +44,7 @@ export function useAutomationSourceHostAvailability(
|
||||
)
|
||||
const mountedRef = useRef(true)
|
||||
const requestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set())
|
||||
const [requestPool] = useState(createAutomationHostRequestPool)
|
||||
const [statusByHostId, setStatusByHostId] = useState<
|
||||
ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus>
|
||||
>(() => new Map())
|
||||
@@ -73,8 +75,9 @@ export function useAutomationSourceHostAvailability(
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false
|
||||
requestPool.cancelQueued()
|
||||
},
|
||||
[]
|
||||
[requestPool]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!preflightStatusCurrent || !preflightStatusChecked) {
|
||||
@@ -108,16 +111,23 @@ export function useAutomationSourceHostAvailability(
|
||||
if (parsed?.kind !== 'runtime') {
|
||||
continue
|
||||
}
|
||||
void callRuntimeRpc<PreflightStatus>(
|
||||
{ kind: 'environment', environmentId: parsed.environmentId },
|
||||
'preflight.check',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
.then((status) => record(hostId, status))
|
||||
.catch(() => record(hostId, null))
|
||||
void requestPool.submit({
|
||||
run: async () => {
|
||||
try {
|
||||
const status = await callRuntimeRpc<PreflightStatus>(
|
||||
{ kind: 'environment', environmentId: parsed.environmentId },
|
||||
'preflight.check',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
record(hostId, status)
|
||||
} catch {
|
||||
record(hostId, null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [runtimeSourceHostIds])
|
||||
}, [requestPool, runtimeSourceHostIds])
|
||||
|
||||
return useMemo(() => {
|
||||
const availabilityByRowKey = new Map<string, TaskSourceHostAvailability[]>()
|
||||
|
||||
@@ -72,6 +72,8 @@ export type AutomationWorkspaceHost =
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
export type AutomationProjectionContext = {
|
||||
/** The process that owns this store; runtime scheduling markers are local only on a runtime. */
|
||||
storageAuthority?: 'desktop' | 'runtime'
|
||||
/** Current registration generation of a saved SSH target; undefined once it is gone. */
|
||||
sshTargetGeneration: (targetId: string) => number | undefined
|
||||
/** `undefined` = the repo is gone; `null` = a local repo; a string = its SSH connection. */
|
||||
@@ -138,17 +140,17 @@ function workspacePinSelector(
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage authority is never inferred here — `schedulerOwner` and
|
||||
* `runContext.hostId` only mark a record unrunnable, and a bare `local` is not
|
||||
* evidence of Self without a resolvable local project behind it.
|
||||
* Runtime scheduling markers are owned only in runtime storage. A bare `local`
|
||||
* still needs a resolvable local project before it is evidence of Self.
|
||||
*/
|
||||
export function projectAutomationSelector(
|
||||
automation: Automation,
|
||||
context: AutomationProjectionContext
|
||||
): AutomationListItemSelector {
|
||||
if (
|
||||
automation.schedulerOwner === 'remote_host_service' ||
|
||||
parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime'
|
||||
context.storageAuthority !== 'runtime' &&
|
||||
(automation.schedulerOwner === 'remote_host_service' ||
|
||||
parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime')
|
||||
) {
|
||||
return orphan(AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere)
|
||||
}
|
||||
@@ -191,7 +193,7 @@ const CAPTURED_HOST_ISSUES: Record<AutomationCapturedHostIssue, true> = {
|
||||
}
|
||||
|
||||
function isCapturedHostIssue(issue: string): issue is AutomationCapturedHostIssue {
|
||||
return Object.prototype.hasOwnProperty.call(CAPTURED_HOST_ISSUES, issue)
|
||||
return Object.hasOwn(CAPTURED_HOST_ISSUES, issue)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,11 +104,10 @@ describe('assertAutomationOwnerFence', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('requires a precondition before mutating a record pinned to a generation', () => {
|
||||
expectConflict(
|
||||
() => assertAutomationOwnerFence({ automation: sshRecord, operation: 'mutate', context }),
|
||||
AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired
|
||||
)
|
||||
it('keeps legacy mutations compatible when no precondition is present', () => {
|
||||
expect(() =>
|
||||
assertAutomationOwnerFence({ automation: sshRecord, operation: 'mutate', context })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('still lets an old client read that record', () => {
|
||||
@@ -125,25 +124,16 @@ describe('assertAutomationOwnerFence', () => {
|
||||
|
||||
// The stored field is absent on a workspace-pinned record while the projection carries a
|
||||
// generation, so keying the demand off storage let precisely this population write unfenced.
|
||||
it('requires a precondition on a record the projection presents as SSH-owned', () => {
|
||||
expectConflict(
|
||||
() =>
|
||||
it('keeps legacy workspace-pinned mutations and execution compatible', () => {
|
||||
for (const operation of ['mutate', 'execute'] as const) {
|
||||
expect(() =>
|
||||
assertAutomationOwnerFence({
|
||||
automation: pinnedLocalRecord,
|
||||
operation: 'mutate',
|
||||
operation,
|
||||
context: pinnedContext
|
||||
}),
|
||||
AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired
|
||||
)
|
||||
expectConflict(
|
||||
() =>
|
||||
assertAutomationOwnerFence({
|
||||
automation: pinnedLocalRecord,
|
||||
operation: 'execute',
|
||||
context: pinnedContext
|
||||
}),
|
||||
AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired
|
||||
)
|
||||
})
|
||||
).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('still lets an old client read a workspace-pinned record', () => {
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
* The owner precondition an authority enforces before it reads, mutates, or
|
||||
* executes one stored automation.
|
||||
*
|
||||
* Optional on the wire is not unenforced: a record the projection owns by an SSH
|
||||
* registration refuses a mutation that names no expected owner, so a client that
|
||||
* skips the field cannot make unfenced writes permanent server behavior. The
|
||||
* precondition stays genuinely optional only where nothing can be fenced — Self
|
||||
* records and orphans. Legacy SSH rows are not a carve-out: the load-time owner
|
||||
* migration stamps every one whose target still resolves, so a survivor without
|
||||
* a capture projects as an orphan rather than as an unfenced SSH owner.
|
||||
* Optional on the wire preserves old-client/new-server compatibility. Current
|
||||
* clients send the capture and get strict enforcement; legacy clients omit it.
|
||||
*/
|
||||
|
||||
import type { Automation } from './automations-types'
|
||||
@@ -79,12 +74,6 @@ export function assertAutomationOwnerFence(input: AutomationOwnerFenceInput): vo
|
||||
conflict('targetRemoved')
|
||||
}
|
||||
if (!expected) {
|
||||
// The projected selector, not the stored field, says what is fenceable: a workspace-pinned
|
||||
// record is SSH-owned with the capture on its pin, so keying off storage let exactly that
|
||||
// population write unfenced. An SSH projection always carries a generation.
|
||||
if (stored.kind === 'ssh' && input.operation !== 'read') {
|
||||
conflict('fencingRequired')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (expected.kind !== stored.kind) {
|
||||
|
||||
Reference in New Issue
Block a user