diff --git a/scripts/sync-agent-host-protocol.ts b/scripts/sync-agent-host-protocol.ts index 87db5d7ab0e6dc..f5b3e879b28b5a 100644 --- a/scripts/sync-agent-host-protocol.ts +++ b/scripts/sync-agent-host-protocol.ts @@ -28,7 +28,7 @@ import { execSync } from 'child_process'; import * as ts from 'typescript'; const ROOT = path.resolve(__dirname, '..'); -const PROTOCOL_REPO = path.resolve(ROOT, '../agent-host-protocol'); +const PROTOCOL_REPO = process.env['AHP_PROTOCOL_REPO'] ?? path.resolve(ROOT, '../agent-host-protocol'); const TYPES_DIR = path.join(PROTOCOL_REPO, 'types'); const DEST_DIR = path.join(ROOT, 'src/vs/platform/agentHost/common/state/protocol'); @@ -219,9 +219,22 @@ function mergeDuplicateImports(content: string): string { }).join('\n'); } - - - +function applyGeneratedSourceFixes(content: string, dest: string): string { + const replaceRequired = (search: string | RegExp, replacement: string): void => { + const next = content.replace(search, replacement); + if (next === content) { + throw new Error(`Required generated-source compatibility fix no longer matches ${dest}`); + } + content = next; + }; + if (dest === 'channels-automation/state.ts') { + replaceRequired( + 'import type { AutomationCreateRequestedAction, AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from \'./actions.js\';', + 'import type { AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from \'./actions.js\';', + ); + } + return content; +} function processFile(src: string, dest: string): void { let content = fs.readFileSync(src, 'utf-8'); @@ -229,6 +242,7 @@ function processFile(src: string, dest: string): void { // Merge duplicate imports from the same module content = mergeDuplicateImports(content); + content = applyGeneratedSourceFixes(content, dest); content = convertIndentation(content); content = content.split('\n').map(line => line.trimEnd()).join('\n'); diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 4d0cb55cfcfb5b..44c4dc8da3c6c7 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -25,7 +25,7 @@ import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubs import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, type IAgentHostResourceUriMapper, toAgentHostUri } from '../common/agentHostUri.js'; import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; -import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; +import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; @@ -36,6 +36,7 @@ import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/error import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { decodeBase64, encodeBase64 } from '../../../base/common/buffer.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; @@ -649,7 +650,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return; } - const subscriptions = this._subscriptionManager.currentSubscriptionUris().map(u => u.toString()); + const subscriptions = this._subscriptionManager.currentSubscriptionChannels(); // Always include the always-live root state alongside getSubscription-managed entries. if (!subscriptions.includes(ROOT_STATE_URI)) { subscriptions.unshift(ROOT_STATE_URI); @@ -745,12 +746,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private async _restoreSubscriptionsAfterFreshInitialize(initialSnapshots: readonly IStateSnapshot[]): Promise { const restored = new Set(initialSnapshots.map(snapshot => snapshot.resource)); const active = this._subscriptionManager.getActiveSubscriptions() - .filter(subscription => !restored.has(subscription.resource.toString())); + .filter(subscription => !restored.has(subscription.channel)); const restoreGroup = async (subscriptions: typeof active) => { await Promise.all(subscriptions.map(async subscription => { try { const result = await this._dispatchRequest('subscribe', { - channel: subscription.resource.toString(), + channel: subscription.channel, }, { bypassReconnectGate: true }); if (result.snapshot) { this._subscriptionManager.applyReconnectSnapshot( @@ -765,8 +766,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (error instanceof ProtocolError && error.code === AHP_CLIENT_CONNECTION_CLOSED) { throw error; } - this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${subscription.resource.toString()} after host restart: ${error instanceof Error ? error.message : String(error)}`); - this._subscriptionManager.markSubscriptionsMissing([subscription.resource]); + this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${subscription.channel} after host restart: ${error instanceof Error ? error.message : String(error)}`); + this._subscriptionManager.markSubscriptionsMissing([subscription.channel]); } })); }; @@ -867,7 +868,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._serverSeq = maxSeq; if (result.missing.length > 0) { this._logService.info(`[RemoteAgentHostProtocol] Server cannot resume ${result.missing.length} subscription(s) after reconnect.`); - this._subscriptionManager.markSubscriptionsMissing(result.missing.map(u => URI.parse(u))); + this._subscriptionManager.markSubscriptionsMissing(result.missing); } } else { let maxSeq = this._serverSeq; @@ -944,6 +945,10 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return this._subscriptionManager.getSubscription(kind, resource, owner); } + getSubscriptionByChannel(kind: StateComponents, channel: string, owner: string): IReference> { + return this._subscriptionManager.getSubscriptionByChannel(kind, channel, owner); + } + getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { return this._subscriptionManager.getSubscriptionUnmanaged(resource); } @@ -960,7 +965,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return this._subscriptionManager.getActiveSubscriptions(); } - dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void { const seq = this._subscriptionManager.dispatchOptimistic(channel, action); this.dispatchAction(channel, action, this._clientId, seq); } @@ -974,12 +979,16 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * response. */ async subscribe(resource: URI): Promise { - this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${resource.toString()}`); - const result = await this._sendRequest('subscribe', { channel: resource.toString() }); + return this._subscribeChannel(resource.toString()); + } + + private async _subscribeChannel(channel: string): Promise { + this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${channel}`); + const result = await this._sendRequest('subscribe', { channel }); if (!result.snapshot) { - throw new Error(`subscribe to ${resource.toString()} returned no snapshot`); + throw new Error(`subscribe to ${channel} returned no snapshot`); } - this._logService.trace(`[RemoteAgentHostProtocol] subscribe done: ${resource.toString()}`); + this._logService.trace(`[RemoteAgentHostProtocol] subscribe done: ${channel}`); return result.snapshot; } @@ -1001,13 +1010,17 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * Unsubscribe from state at a URI. */ unsubscribe(resource: URI): void { - this._sendNotification('unsubscribe', { channel: resource.toString() }); + this._unsubscribeChannel(resource.toString()); + } + + private _unsubscribeChannel(channel: string): void { + this._sendNotification('unsubscribe', { channel }); } /** * Dispatch a client action to the server. Returns the clientSeq used. */ - private dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, _clientId: string, clientSeq: number): void { + private dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction, _clientId: string, clientSeq: number): void { this._grantImplicitReadsForOutgoingAction(action); this._sendNotification('dispatchAction', { channel, clientSeq, action }); } @@ -1063,6 +1076,18 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return this._sendRequest('completions', params); } + async listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise { + return this._sendRequest('listAutomationTriggerDefinitions', params); + } + + async runAutomation(params: RunAutomationParams): Promise { + return this._sendRequest('runAutomation', params); + } + + async fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { + return this._sendRequest('fetchAutomationRuns', params); + } + /** * Send an application-level ping and wait for the server's response. * Used by {@link _watchdogTick} to keep idle connections under @@ -1310,7 +1335,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * Inspect an outgoing client-dispatched action and grant implicit reads for * resources that the host will need to read after receiving the action. */ - private _grantImplicitReadsForOutgoingAction(action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + private _grantImplicitReadsForOutgoingAction(action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void { switch (action.type) { case ActionType.SessionActiveClientSet: if (action.activeClient.customizations) { diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index c2de0b672f2689..30a80fab4c7697 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -13,7 +13,8 @@ import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/stat import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction, ClientAnnotationsAction } from '../common/state/sessionActions.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; +import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; @@ -45,10 +46,11 @@ export class NullAgentHostService implements IAgentHostService { get rootState(): IAgentSubscription { return notSupported(); } getSubscription(_kind: T, _resource: URI, _owner: string): IReference> { return notSupported(); } + getSubscriptionByChannel(_kind: T, _channel: string, _owner: string): IReference> { return notSupported(); } getSubscriptionUnmanaged(_kind: T, _resource: URI): IAgentSubscription | undefined { return undefined; } getInflightSessionCreate(_resource: URI): Promise | undefined { return undefined; } getActiveSubscriptions(): readonly IActiveSubscriptionInfo[] { return []; } - dispatch(_channel: string, _action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { notSupported(); } + dispatch(_channel: string, _action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void { notSupported(); } async restartAgentHost(): Promise { notSupported(); } async authenticate(_params: AuthenticateParams): Promise { return notSupported(); } @@ -63,6 +65,9 @@ export class NullAgentHostService implements IAgentHostService { async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } async completions(_params: CompletionsParams): Promise { return { items: [] }; } + async listAutomationTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { return notSupported(); } + async runAutomation(_params: RunAutomationParams): Promise { return notSupported(); } + async fetchAutomationRuns(_params: FetchAutomationRunsParams): Promise { return notSupported(); } async getCompletionTriggerCharacters(): Promise { return []; } async startWebSocketServer(): Promise { return notSupported(); } async getInspectInfo(_tryEnable: boolean): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 628ea750bdd884..075c4aeeb32d6f 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -18,9 +18,10 @@ import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; import type { IAgentHostResourceUriMapper } from './agentHostUri.js'; import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; -import type { InitializeResult } from './state/protocol/common/commands.js'; +import type { AutomationCapabilities, InitializeResult } from './state/protocol/common/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction, ClientChangesetAction } from './state/sessionActions.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from './state/protocol/channels-automation/commands.js'; +import type { ActionEnvelope, ClientAutomationAction, ClientAutomationRunAction, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction, ClientChangesetAction } from './state/sessionActions.js'; import type { ContentEncoding, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; import { ComponentToState, StateComponents, type RootState } from './state/sessionState.js'; import { type AgentProvider, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type AuthenticateParams, type AuthenticateResult, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IMcpNotification, type IAgentHostNetworkEndpoint, type IAgentHostManagedSettingsSnapshot } from './agent.js'; @@ -959,6 +960,11 @@ export interface IAgentService { */ readonly onDidNotification: Event; + readonly automationCapabilities: AutomationCapabilities | undefined; + listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise; + runAutomation(params: RunAutomationParams): Promise; + fetchAutomationRuns(params: FetchAutomationRunsParams): Promise; + /** * Dispatch a client-originated action to the server. The server applies * it to state, triggers side effects, and echoes it back via @@ -970,7 +976,7 @@ export interface IAgentService { * rather than {@link URI} objects so that authority-less scheme URIs * like `ahp-root://` survive the wire format without normalization. */ - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void; + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void; /** * List the contents of a directory on the agent host's filesystem. @@ -1052,6 +1058,8 @@ export interface IAgentConnection { * acquiring class name. */ getSubscription(kind: T, resource: URI, owner: string): IReference>; + /** Acquire a subscription using an exact protocol channel string that must not be URI-normalized. */ + getSubscriptionByChannel(kind: T, channel: string, owner: string): IReference>; getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; /** @@ -1077,7 +1085,7 @@ export interface IAgentConnection { * than {@link URI} objects so authority-less scheme URIs like * `ahp-root://` survive the wire format without normalization. */ - dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): void; + dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void; // ---- Events (connection-level) ------------------------------------------ readonly onDidNotification: Event; @@ -1111,6 +1119,9 @@ export interface IAgentConnection { resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; completions(params: CompletionsParams): Promise; + listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise; + runAutomation(params: RunAutomationParams): Promise; + fetchAutomationRuns(params: FetchAutomationRunsParams): Promise; /** * Trigger characters announced by the connected agent host that should diff --git a/src/vs/platform/agentHost/common/automationMigration.ts b/src/vs/platform/agentHost/common/automationMigration.ts new file mode 100644 index 00000000000000..16b4683b825852 --- /dev/null +++ b/src/vs/platform/agentHost/common/automationMigration.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY = 'vscode.automationMigration'; +export const AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY = 'automationsEnabled'; +export const AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY = 'automationRunTimeoutMinutes'; +export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY = 'vscode.legacyAutomationImport'; +export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY = 'vscode.legacyAutomationImportPending'; +export const AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY = 'vscode.migrationCompleted'; + +export interface IAgentHostAutomationMigrationCompletion { + readonly version: 1; + readonly status: 'complete'; + readonly resources: readonly string[]; +} + +export function isAgentHostAutomationMigrationCompletion(value: unknown): value is IAgentHostAutomationMigrationCompletion { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const candidate = value as Record; + if (candidate['version'] !== 1 || candidate['status'] !== 'complete' || !Array.isArray(candidate['resources'])) { + return false; + } + const resources = candidate['resources']; + return resources.every(resource => typeof resource === 'string') && new Set(resources).size === resources.length; +} diff --git a/src/vs/platform/agentHost/common/meta/automationMeta.ts b/src/vs/platform/agentHost/common/meta/automationMeta.ts new file mode 100644 index 00000000000000..7da15e1e27113b --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/automationMeta.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../automationMigration.js'; + +interface IHasAutomationMeta { + readonly _meta?: Record; +} + +/** Whether the Automation catalogue records durable legacy migration completion. */ +export function isAgentHostAutomationCatalogMigrated(source: IHasAutomationMeta): boolean { + return readAutomationMetaSlot(source, AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY) === true; +} + +/** Whether an Automation definition was imported from VS Code's legacy store. */ +export function isAgentHostLegacyAutomationImport(source: IHasAutomationMeta | undefined): boolean { + return source !== undefined && readAutomationMetaSlot(source, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY) === true; +} + +/** + * Whether a legacy-imported Automation definition is still waiting for its + * originating legacy source row to be durably removed. Pending items must not + * be granted host `Run` (or `Remove`) authority because the browser-side + * legacy scheduler still owns the row until the removal succeeds. + */ +export function isAgentHostLegacyAutomationImportPending(source: IHasAutomationMeta | undefined): boolean { + return source !== undefined && readAutomationMetaSlot(source, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY) === true; +} + +function readAutomationMetaSlot(source: IHasAutomationMeta, key: string): unknown { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned reader for validated Automation metadata slots. + return source._meta?.[key]; +} diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 53a15afeca06dd..96e0406b5d86aa 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -9,13 +9,13 @@ import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { ActionEnvelope, ActionType, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js'; -import { changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js'; +import { ActionEnvelope, ActionType, type AutomationAction, type AutomationRunAction, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js'; +import { automationReducer, automationRunReducer, changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js'; import { terminalReducer } from './protocol/reducers.js'; import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as IProtocolChatAction, TerminalAction } from './protocol/action-origin.generated.js'; -import type { AnnotationsState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; +import type { AnnotationsState, AutomationCatalogState, AutomationRunState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; import type { IStateSnapshot } from './sessionProtocol.js'; -import { isAhpRootChannel, ROOT_STATE_URI, StateComponents } from './sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isAhpRootChannel, ROOT_STATE_URI, StateComponents } from './sessionState.js'; import { normalizeLegacyChatStateErrors } from './legacyProtocolCompatibility.js'; // --- Public API -------------------------------------------------------------- @@ -65,6 +65,8 @@ export interface IAgentSubscription { export interface IActiveSubscriptionInfo { /** The protocol resource URI subscribed to. */ readonly resource: URI; + /** Exact protocol channel string used on the wire. */ + readonly channel: string; /** Which state component this subscription tracks. */ readonly kind: StateComponents; /** Number of outstanding {@link IReference} holders. */ @@ -213,6 +215,9 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs * Session subscriptions override this for write-ahead. */ protected _reconcile(envelope: ActionEnvelope, _isOwnAction: boolean): void { + if (envelope.rejectionReason) { + return; + } this._confirmedState = this._applyReducer(this._confirmedState!, envelope.action); this._onDidChange.fire(this.value as T); } @@ -573,6 +578,53 @@ export class TerminalStateSubscription extends BaseAgentSubscription { + + constructor(clientId: string, log: (msg: string) => void) { + super(clientId, log); + } + + protected override _applyReducer(state: AutomationCatalogState, action: StateAction): AutomationCatalogState { + return automationReducer(state, action as AutomationAction, this._log); + } + + protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean { + return isAhpAutomationCatalogChannel(envelope.channel); + } + + protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void { + if (!envelope.rejectionReason) { + super._reconcile(envelope, isOwnAction); + } + } +} + +/** Subscription to one host-owned automation run. */ +export class AutomationRunSubscription extends BaseAgentSubscription { + + private readonly _resource: string; + + constructor(resource: string, clientId: string, log: (msg: string) => void) { + super(clientId, log); + this._resource = resource; + } + + protected override _applyReducer(state: AutomationRunState, action: StateAction): AutomationRunState { + return automationRunReducer(state, action as AutomationRunAction, this._log); + } + + protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean { + return isAhpAutomationRunChannel(envelope.channel) && envelope.channel === this._resource; + } + + protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void { + if (!envelope.rejectionReason) { + super._reconcile(envelope, isOwnAction); + } + } +} + // --- Changeset State Subscription -------------------------------------------- /** @@ -671,7 +723,7 @@ export class ChangesetStateSubscription extends BaseAgentSubscription }; +type ManagedSubscriptionEntry = { + readonly resource: URI; + readonly channel: string; + readonly sub: ManagedSubscription; + readonly kind: StateComponents; + refCount: number; + readonly holders: Map; +}; // --- Subscription Manager ---------------------------------------------------- @@ -904,30 +963,38 @@ export class AgentSubscriptionManager extends Disposable { * acquiring class name. */ getSubscription(kind: StateComponents, resource: URI, owner: string): IReference> { - const existing = this._subscriptions.get(resource); + return this._getSubscription(kind, this._subscriptionResource(resource), owner); + } + + /** Get or create a subscription using an exact protocol channel string. */ + getSubscriptionByChannel(kind: StateComponents, channel: string, owner: string): IReference> { + return this._getSubscription(kind, this._subscriptionResource(URI.parse(channel)), owner); + } + + private _getSubscription(kind: StateComponents, resolved: Pick, owner: string): IReference> { + const existing = this._subscriptions.get(resolved.resource); if (existing) { if (existing.sub.value instanceof Error) { // Failed subscriptions should not poison the resource forever. Evict // the errored entry so this acquire performs a fresh subscribe. - this._subscriptions.delete(resource); - this._disposeSubscriptionEntry(resource, existing); + this._subscriptions.delete(resolved.resource); + this._disposeSubscriptionEntry(existing); } else { existing.refCount++; - return this._acquireReference(resource, existing, owner); + return this._acquireReference(existing, owner); } } // Create new subscription based on caller-specified kind - const key = resource.toString(); - const sub = this._createSubscription(kind, key); - const entry: ManagedSubscriptionEntry = { sub, kind, refCount: 1, holders: new Map() }; - this._subscriptions.set(resource, entry); + const sub = this._createSubscription(kind, resolved.channel); + const entry: ManagedSubscriptionEntry = { ...resolved, sub, kind, refCount: 1, holders: new Map() }; + this._subscriptions.set(resolved.resource, entry); // Kick off server subscription asynchronously. // Capture the entry reference so we can validate it hasn't been // replaced by a new subscription for the same key (race guard). void (async () => { - const inflight = this._inflightCreates.get(resource); + const inflight = this._inflightCreates.get(resolved.resource); if (inflight) { try { await inflight; @@ -938,18 +1005,18 @@ export class AgentSubscriptionManager extends Disposable { } } try { - const snapshot = await this._subscribe(resource); - if (this._subscriptions.get(resource) === entry) { + const snapshot = await this._subscribe(resolved.resource); + if (this._subscriptions.get(resolved.resource) === entry) { sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); } } catch (err) { - if (this._subscriptions.get(resource) === entry) { + if (this._subscriptions.get(resolved.resource) === entry) { sub.setError(err instanceof Error ? err : new Error(String(err))); } } })(); - return this._acquireReference(resource, entry, owner); + return this._acquireReference(entry, owner); } /** @@ -958,7 +1025,7 @@ export class AgentSubscriptionManager extends Disposable { * caller is responsible for the matching refcount increment (a fresh * entry starts at 1; an existing entry is bumped before calling this). */ - private _acquireReference(resource: URI, entry: ManagedSubscriptionEntry, owner: string): IReference> { + private _acquireReference(entry: ManagedSubscriptionEntry, owner: string): IReference> { const ownerId = ++this._referenceOwnerIds; entry.holders.set(ownerId, owner); @@ -971,13 +1038,13 @@ export class AgentSubscriptionManager extends Disposable { } isDisposed = true; entry.holders.delete(ownerId); - this._releaseSubscription(resource, entry); + this._releaseSubscription(entry); }, }; } - private _disposeSubscriptionEntry(resource: URI, entry: ManagedSubscriptionEntry): void { - this._tryUnsubscribe(resource); + private _disposeSubscriptionEntry(entry: ManagedSubscriptionEntry): void { + this._tryUnsubscribe(entry.resource); if (entry.sub instanceof SessionStateSubscription || entry.sub instanceof ChatStateSubscription || entry.sub instanceof AnnotationsStateSubscription) { entry.sub.clearPending(); } @@ -1012,7 +1079,7 @@ export class AgentSubscriptionManager extends Disposable { * `channel` is the protocol URI string identifying the channel the * action targets (a session URI for session actions, etc.). */ - dispatchOptimistic(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): number { + dispatchOptimistic(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): number { if (isSessionAction(action)) { const entry = this._subscriptions.get(URI.parse(channel)); if (entry?.sub instanceof SessionStateSubscription) { @@ -1045,8 +1112,8 @@ export class AgentSubscriptionManager extends Disposable { * Does NOT include the always-live root state, which the protocol * client manages separately. */ - currentSubscriptionUris(): URI[] { - return [...this._subscriptions.keys()]; + currentSubscriptionChannels(): string[] { + return [...this._subscriptions.values()].map(entry => entry.channel); } /** @@ -1056,10 +1123,10 @@ export class AgentSubscriptionManager extends Disposable { */ getActiveSubscriptions(): readonly IActiveSubscriptionInfo[] { const out: IActiveSubscriptionInfo[] = []; - for (const [resource, entry] of this._subscriptions) { + for (const entry of this._subscriptions.values()) { const value = entry.sub.value; const status = value === undefined ? 'pending' : value instanceof Error ? 'error' : 'snapshot'; - out.push({ resource, kind: entry.kind, refCount: entry.refCount, holders: this._summarizeHolders(entry), status }); + out.push({ resource: entry.resource, channel: entry.channel, kind: entry.kind, refCount: entry.refCount, holders: this._summarizeHolders(entry), status }); } return out; } @@ -1135,14 +1202,14 @@ export class AgentSubscriptionManager extends Disposable { * themselves stay alive so consumers continue to hold valid references, * but their value transitions to an `Error` until they're recreated. */ - markSubscriptionsMissing(missing: readonly URI[]): void { - for (const resource of missing) { - const entry = this._subscriptions.get(resource); + markSubscriptionsMissing(missing: readonly string[]): void { + for (const channel of missing) { + const entry = this._subscriptions.get(URI.parse(channel)); if (entry) { if (entry.sub instanceof SessionStateSubscription || entry.sub instanceof ChatStateSubscription || entry.sub instanceof AnnotationsStateSubscription) { entry.sub.clearPending(); } - entry.sub.setError(new Error(`Subscription no longer available after reconnect: ${resource.toString()}`)); + entry.sub.setError(new Error(`Subscription no longer available after reconnect: ${entry.channel}`)); } } } @@ -1159,6 +1226,10 @@ export class AgentSubscriptionManager extends Disposable { return new ChangesetStateSubscription(key, this._clientId, this._seqAllocator, this._log); case StateComponents.Annotations: return new AnnotationsStateSubscription(key, this._clientId, this._seqAllocator, this._log); + case StateComponents.AutomationCatalog: + return new AutomationCatalogSubscription(this._clientId, this._log); + case StateComponents.AutomationRun: + return new AutomationRunSubscription(key, this._clientId, this._log); case StateComponents.Root: throw new Error('_createSubscription: root subscription is managed separately'); default: @@ -1166,28 +1237,35 @@ export class AgentSubscriptionManager extends Disposable { } } - private _releaseSubscription(resource: URI, expected?: ManagedSubscriptionEntry): void { - const entry = this._subscriptions.get(resource); + private _releaseSubscription(expected: ManagedSubscriptionEntry): void { + const entry = this._subscriptions.get(expected.resource); // A failed subscription can be evicted and replaced while old references // still exist; stale disposals must not release the replacement entry. - if (!entry || (expected && entry !== expected)) { + if (!entry || entry !== expected) { return; } entry.refCount--; if (entry.refCount <= 0) { - this._subscriptions.delete(resource); - this._disposeSubscriptionEntry(resource, entry); + this._subscriptions.delete(entry.resource); + this._disposeSubscriptionEntry(entry); } } override dispose(): void { - for (const [resource, entry] of this._subscriptions) { - this._tryUnsubscribe(resource); + for (const entry of this._subscriptions.values()) { + this._tryUnsubscribe(entry.resource); entry.sub.dispose(); } this._subscriptions.clear(); super.dispose(); } + + private _subscriptionResource(resource: URI): Pick { + return { + resource, + channel: resource.toString(), + }; + } } /** Returns whether an action envelope targets one of the subscribed channel URIs. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts index 5e56622dab4b10..e7aec7c2fbc284 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts @@ -63,7 +63,7 @@ export interface ListAutomationTriggerDefinitionsResult { */ export interface RunAutomationParams extends BaseParams { /** Manual runs are scoped to the catalogue channel. */ - channel: 'ahp-automations://'; + channel: 'ahp-automations://catalog'; /** Target {@link AutomationState.resource}. */ automation: URI; /** @@ -89,7 +89,7 @@ export interface RunAutomationResult { * * The response only acknowledges the request. The updated full state arrives * through {@link AutomationSetAction | `automation/set`} on the - * `ahp-automations://` channel, keeping all catalogue subscribers synchronized + * `ahp-automations://catalog` channel, keeping all catalogue subscribers synchronized * through the normal action stream. * * @category Commands @@ -100,7 +100,7 @@ export interface RunAutomationResult { */ export interface FetchAutomationRunsParams extends BaseParams { /** Run-history loading is scoped to the catalogue channel. */ - channel: 'ahp-automations://'; + channel: 'ahp-automations://catalog'; /** Target {@link AutomationState.resource}. */ automation: URI; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts index 19ba4f56b3e401..95f2455f81e20d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts @@ -319,7 +319,7 @@ export interface AutomationState { } /** - * Authoritative automation catalogue exposed on the `ahp-automations://` + * Authoritative automation catalogue exposed on the `ahp-automations://catalog` * channel. * * A subscription snapshot contains every automation visible to the client. diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index c959cc2c33bce5..05000eb38ec5dc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -281,7 +281,7 @@ export interface InitializeResult { telemetry?: TelemetryCapabilities; /** * Host-owned automation support. Presence means clients may subscribe to - * `ahp-automations://` for {@link AutomationCatalogState}; absence means the + * `ahp-automations://catalog` for {@link AutomationCatalogState}; absence means the * host does not expose an automation catalogue or automation commands. * * @see {@link /guide/automations | Automations Guide} @@ -292,7 +292,7 @@ export interface InitializeResult { /** * Automation features supported by this host authority. * - * The presence of this object advertises the baseline `ahp-automations://` + * The presence of this object advertises the baseline `ahp-automations://catalog` * catalogue. Optional fields describe additional host features and * restrictions. * diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index e5be409c1cb24d..563712337ba61d 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -47,6 +47,7 @@ export { type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, + type SessionWorkingDirectoryReplacedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type ChatPendingMessageSetAction, @@ -71,6 +72,15 @@ export { type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type ResourceWatchChangedAction, + type AutomationCreateRequestedAction, + type AutomationUpdateRequestedAction, + type AutomationSetAction, + type AutomationRemovedAction, + type AutomationRunLifecycleChangedAction, + type AutomationRunSessionSetAction, + type AutomationRunSessionRemovedAction, + type AutomationRunPrimarySessionChangedAction, + type AutomationRunCancelRequestedAction, type StateAction, } from './protocol/actions.js'; @@ -131,11 +141,12 @@ import { type SessionIsArchivedChangedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, + type SessionWorkingDirectoryReplacedAction, type RootConfigChangedAction, } from './protocol/actions.js'; import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; -import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js'; +import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_, AutomationAction as IAutomationAction_, ClientAutomationAction as IClientAutomationAction_, AutomationRunAction as IAutomationRunAction_, ClientAutomationRunAction as IClientAutomationRunAction_ } from './protocol/action-origin.generated.js'; /** * Discriminated union of all server→client protocol notifications other than @@ -163,6 +174,10 @@ export type ChangesetAction = IChangesetAction_; export type ClientChangesetAction = IClientChangesetAction_; export type AnnotationsAction = IAnnotationsAction_; export type ClientAnnotationsAction = IClientAnnotationsAction_; +export type AutomationAction = IAutomationAction_; +export type ClientAutomationAction = IClientAutomationAction_; +export type AutomationRunAction = IAutomationRunAction_; +export type ClientAutomationRunAction = IClientAutomationRunAction_; // Root actions export type IAgentsChangedAction = RootAgentsChangedAction; @@ -200,7 +215,8 @@ export type IIsArchivedChangedAction = SessionIsArchivedChangedAction; /** Session-level working-directory mutations. */ export type SessionWorkingDirectoryAction = | SessionWorkingDirectorySetAction - | SessionWorkingDirectoryRemovedAction; + | SessionWorkingDirectoryRemovedAction + | SessionWorkingDirectoryReplacedAction; // Notifications export type INotification = ProtocolNotification; @@ -231,6 +247,14 @@ export function isAnnotationsAction(action: StateAction): action is AnnotationsA return action.type.startsWith('annotations/'); } +export function isAutomationAction(action: StateAction): action is AutomationAction { + return action.type.startsWith('automation/'); +} + +export function isAutomationRunAction(action: StateAction): action is AutomationRunAction { + return action.type.startsWith('automationRun/'); +} + /** * Whether `action` only toggles durable session metadata (archived / read) * rather than mutating the conversation. These carry no intent to open a diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index ba86e3e29e6afe..8e2fbd41cf725f 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -7,7 +7,7 @@ // The actual reducer logic lives in the auto-generated protocol layer. // Re-export reducers from the protocol layer -export { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; +export { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; import { readToolCallMeta, type ToolKind } from '../meta/agentToolCallMeta.js'; import type { ICompletedToolCall, ToolCallState } from './sessionState.js'; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 81df9aff5812d0..b9c01605ce92e2 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -37,6 +37,8 @@ import { type PendingMessage, type Turn, type AnnotationsState, + type AutomationCatalogState, + type AutomationRunState, type URI as ProtocolURI, type RootState, type SessionState, @@ -74,7 +76,9 @@ export { type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, type ErrorResponsePart, type ResponsePart, type RootState, type RuleCustomization, type SessionActiveClient, - type SessionConfigState, type SessionModelInfo, + type AutomationCatalogState, type AutomationRunState, + type SessionConfigState, + type SessionModelInfo, type SessionState, type SessionSummary, type SkillCustomization, type Snapshot, type StringOrMarkdown, type TerminalState, type TextRange, type ToolAnnotations, @@ -243,6 +247,31 @@ export interface UsageInfoMeta { [key: string]: unknown; } +/** + * Singleton channel containing the host-owned automation catalogue. + * + * The `catalog` authority is appended so the URI round-trips through + * `.toString()`. Without an authority, `ahp-automations://` serializes back to + * `ahp-automations:` and no longer matches. Comparing catalogue channels as + * URIs everywhere (ResourceMap/isEqual) is the intended followup. See + * https://github.com/microsoft/vscode/pull/331796#discussion_r3857160917. + */ +export const AUTOMATION_CATALOG_URI = 'ahp-automations://catalog'; + +/** Returns whether `uri` identifies the singleton automation catalogue channel. */ +export function isAhpAutomationCatalogChannel(uri: string): boolean { + return uri === AUTOMATION_CATALOG_URI; +} + +/** Returns whether `uri` identifies one automation-run channel. */ +export function isAhpAutomationRunChannel(uri: string): boolean { + try { + return ResourceURI.parse(uri).scheme === 'ahp-automation-run'; + } catch { + return false; + } +} + const MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.hiddenFromTranscript'; const MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX = '\n'; @@ -1028,6 +1057,8 @@ export const enum StateComponents { Terminal, Changeset, Annotations, + AutomationCatalog, + AutomationRun, } export type ComponentToState = { @@ -1037,6 +1068,8 @@ export type ComponentToState = { [StateComponents.Terminal]: TerminalState; [StateComponents.Changeset]: ChangesetState; [StateComponents.Annotations]: AnnotationsState; + [StateComponents.AutomationCatalog]: AutomationCatalogState; + [StateComponents.AutomationRun]: AutomationRunState; }; // ---- Default chat URI helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts b/src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts index ba7dc160571d06..543be2cdf3d3ce 100644 --- a/src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts +++ b/src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts @@ -45,17 +45,29 @@ export function areSessionWorkingDirectoriesEqual(first: readonly URI[] | undefi && areDirectorySetsEqual(first.slice(1), second.slice(1)); } +/** + * Provider working-directory capability flags relevant to authoritative + * host-side validation. Mirrors {@link MultipleWorkingDirectoriesCapability} + * fields without the transport concerns. + */ +export interface ISessionWorkingDirectoryCapability { + readonly immutablePrimary: boolean; + readonly primaryReplacement: boolean; +} + /** * Validates and canonicalizes a working-directory delta against the session's * current host-side URI identities. The returned spelling is safe for the - * exact-string session reducer. `hasImmutablePrimary` reflects the owning - * agent's capability: when `true` the first entry of `workingDirectories` is a - * fixed process root that cannot be removed. + * exact-string session reducer. `capability` reflects the owning agent's + * multiple-working-directories capability: `immutablePrimary` fixes the first + * entry as a process root that cannot be removed via the generic membership + * action, and `primaryReplacement` protects index 0 as a replaceable primary + * that may only change through `SessionWorkingDirectoryReplaced`. */ export function resolveSessionWorkingDirectoryAction( action: SessionWorkingDirectoryAction, workingDirectories: readonly string[], - hasImmutablePrimary: boolean, + capability: ISessionWorkingDirectoryCapability, ): SessionWorkingDirectoryAction { const directory = URI.parse(action.directory, true); if (directory.scheme !== Schemas.file) { @@ -64,10 +76,34 @@ export function resolveSessionWorkingDirectoryAction( const current = workingDirectories.map(value => URI.parse(value, true)); const index = current.findIndex(value => extUriBiasedIgnorePathCase.isEqual(value, directory)); - if (hasImmutablePrimary && action.type === ActionType.SessionWorkingDirectoryRemoved && index === 0) { - throw new Error('The primary working directory cannot be removed.'); + const canonicalDirectory = index >= 0 ? current[index] : directory; + + if (action.type === ActionType.SessionWorkingDirectoryRemoved) { + // The generic membership action MUST NOT remove index 0 when the primary + // is either fixed or a protected-replaceable slot; the replace action is + // the only path in the latter case. + if (index === 0 && (capability.immutablePrimary || capability.primaryReplacement)) { + throw new Error('The primary working directory cannot be removed.'); + } + return { ...action, directory: canonicalDirectory.toString() }; + } + + if (action.type === ActionType.SessionWorkingDirectoryReplaced) { + const replacement = URI.parse(action.replacement, true); + if (replacement.scheme !== Schemas.file) { + throw new Error(`Working directory replacement must be a file URI: ${action.replacement}`); + } + // Index 0 may only be replaced when the provider advertises + // primaryReplacement. An immutable primary without primaryReplacement + // is fixed and cannot be swapped; a plain equal-peer index 0 (neither + // flag set) is fine to replace like any other entry. + if (index === 0 && capability.immutablePrimary && !capability.primaryReplacement) { + throw new Error('The primary working directory cannot be replaced.'); + } + const replacementIdx = current.findIndex(value => extUriBiasedIgnorePathCase.isEqual(value, replacement)); + const canonicalReplacement = replacementIdx >= 0 ? current[replacementIdx] : replacement; + return { ...action, directory: canonicalDirectory.toString(), replacement: canonicalReplacement.toString() }; } - const canonicalDirectory = index >= 0 ? current[index] : directory; return { ...action, directory: canonicalDirectory.toString() }; } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 3e0d9803c0b799..f6724952f66cf1 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -64,8 +64,9 @@ import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTermi import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; -import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; const LOG_PREFIX = '[AgentHost:renderer]'; @@ -366,6 +367,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().getSubscription(kind, resource, owner); } + getSubscriptionByChannel(kind: T, channel: string, owner: string): IReference> { + return this._requireClient().getSubscriptionByChannel(kind, channel, owner); + } + getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined { return this._protocolClient?.getSubscriptionUnmanaged(kind, resource); } @@ -378,7 +383,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._protocolClient?.getActiveSubscriptions() ?? []; } - dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void { this._requireClient().dispatch(channel, action); } @@ -433,6 +438,18 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().completions(params); } + listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise { + return this._requireClient().listAutomationTriggerDefinitions(params); + } + + runAutomation(params: RunAutomationParams): Promise { + return this._requireClient().runAutomation(params); + } + + fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { + return this._requireClient().fetchAutomationRuns(params); + } + getCompletionTriggerCharacters(): Promise { return this._requireClient().getCompletionTriggerCharacters(); } diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts new file mode 100644 index 00000000000000..18bd7a7ae275b3 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -0,0 +1,1083 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../base/common/errorMessage.js'; +import { disposableTimeout } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { equals } from '../../../base/common/objects.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { ActionType, type ActionEnvelope, type AutomationCreateRequestedAction, type AutomationRemovedAction, type AutomationRunCancelRequestedAction, type AutomationRunLifecycleChangedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunSessionSetAction, type AutomationUpdateRequestedAction } from '../common/state/sessionActions.js'; +import { AUTOMATION_CATALOG_URI, isDefaultChatUri, parseRequiredSessionUriFromChatUri, type AutomationCatalogState, type Message } from '../common/state/sessionState.js'; +import { automationReducer } from '../common/state/sessionReducers.js'; +import type { AutomationCapabilities } from '../common/state/protocol/common/commands.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; +import { AutomationMisfirePolicy, AutomationOperation, AutomationTriggerKind, type AutomationDefinition, type AutomationSessionTemplate, type AutomationState } from '../common/state/protocol/channels-automation/state.js'; +import { AutomationRunOriginKind, AutomationRunStatus, type AutomationRunLifecycle, type AutomationRunOrigin, type AutomationRunState, type AutomationRunSummary } from '../common/state/protocol/channels-automation-run/state.js'; +import { MessageKind } from '../common/state/protocol/channels-chat/state.js'; +import { IAgentHostStateManager, type AgentHostStateManager } from './agentHostStateManager.js'; +import { IAgentHostStorageService } from './agentHostStorageService.js'; +import { nextAutomationCronOccurrence, validateAutomationCron } from './automationCron.js'; +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY } from '../common/automationMigration.js'; +import { isAgentHostLegacyAutomationImportPending } from '../common/meta/automationMeta.js'; + +const STORAGE_KEY = 'automations'; +const SCHEDULE_CURSORS_META_KEY = 'vscode.scheduleCursors'; +const SCHEDULE_RETRY_DELAY_MS = 60_000; +const RUN_HISTORY_PAGE_SIZE = 50; +const DEFAULT_RUN_TIMEOUT_MINUTES = 30; + +interface IStoredManualRunRequest { + readonly requestId: string; + readonly automation: string; + readonly run: string; +} + +interface IStoredAutomations { + readonly version?: 1; + readonly catalog: AutomationCatalogState; + readonly runs?: readonly AutomationRunState[]; + readonly manualRunRequests?: readonly IStoredManualRunRequest[]; + readonly migration?: { + readonly status: 'complete'; + readonly completedAt: string; + }; +} + +export interface IAgentHostAutomationExecution { + isSessionTemplateAvailable(template: AutomationSessionTemplate): boolean; + createSession(template: AutomationSessionTemplate, run: AutomationRunState): Promise; + startSession(session: URI, message: Message): Promise; + cancelSession(session: URI): Promise; +} + +export const IAgentHostAutomationService = createDecorator('agentHostAutomationService'); + +export interface IAgentHostAutomationService { + readonly _serviceBrand: undefined; + readonly capabilities: AutomationCapabilities | undefined; + readonly isAvailable: boolean; + handleCreate(action: AutomationCreateRequestedAction): Promise; + handleUpdate(action: AutomationUpdateRequestedAction): Promise; + handleRemove(action: AutomationRemovedAction): Promise; + handleCancel(resource: string, action: AutomationRunCancelRequestedAction): Promise; + listTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise; + runAutomation(params: RunAutomationParams): Promise; + fetchAutomationRuns(params: FetchAutomationRunsParams): Promise; + completeMigration(expectedResources?: readonly string[]): Promise; + handleConfigurationChanged(): Promise; + handleAgentsChanged(): void; +} + +/** + * Owns the durable automation catalogue. A mutation is persisted before its + * corresponding AHP action is published, so a published definition always has + * a durable recovery point after an agent-host restart. + */ +export class AgentHostAutomationService extends Disposable implements IAgentHostAutomationService { + declare readonly _serviceBrand: undefined; + + private _catalog: AutomationCatalogState | undefined; + private _migrationCompletedAt: string | undefined; + private _runs = new Map(); + private _manualRunRequests = new Map(); + private _mutationTail: Promise = Promise.resolve(); + private readonly _scheduleTimer = this._register(new MutableDisposable()); + private readonly _runTimeouts = this._register(new DisposableMap()); + private _didRecoverRuns = false; + + constructor( + private readonly _execution: IAgentHostAutomationExecution, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + const stored = this._load(); + this._migrationCompletedAt = stored?.migration?.completedAt; + this._runs = new Map(stored?.runs?.map(run => [run.resource, run])); + this._catalog = stored?.catalog ? { + ...stored.catalog, + automations: stored.catalog.automations.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), + ...(this._migrationCompletedAt ? { _meta: { ...stored.catalog._meta, [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } } : {}), + } : undefined; + this._manualRunRequests = new Map(stored?.manualRunRequests?.map(request => [request.requestId, request])); + if (this._catalog) { + this._stateManager.setAutomationCatalogState(this._catalog); + } + for (const run of this._runs.values()) { + this._stateManager.setAutomationRunState(run); + } + this._register(this._stateManager.onDidEmitEnvelope(envelope => this._handleEnvelope(envelope))); + if (this._migrationCompletedAt && this._isAutomationsEnabled()) { + void Promise.resolve().then(() => { + this._recoverRuns(); + this._scheduleNext(); + }); + } + } + + get isAvailable(): boolean { + return this._catalog !== undefined; + } + + get capabilities(): AutomationCapabilities | undefined { + return this.isAvailable ? { + create: {}, + schedules: {}, + runCancellation: {}, + runHistoryLimit: RUN_HISTORY_PAGE_SIZE, + } : undefined; + } + + async completeMigration(expectedResources?: readonly string[]): Promise { + return this._enqueueMutation(async () => { + const catalog = this._requireCatalog(); + if (!this._isAutomationsEnabled()) { + throw new Error('Automations must be enabled before migration can complete.'); + } + if (this._migrationCompletedAt !== undefined) { + return; + } + const missing = (expectedResources ?? catalog.automations.map(automation => automation.resource)) + .filter(resource => !catalog.automations.some(automation => automation.resource === resource)); + if (missing.length > 0) { + throw new Error(`Automation migration is incomplete; ${missing.length} expected automation resources are missing.`); + } + const completedAt = new Date().toISOString(); + // Set the completion marker before synthesizing operations so + // `_canGrantRun` sees migration as complete. Roll back if persist + // fails to preserve the pre-migration invariants. + const priorCompletedAt = this._migrationCompletedAt; + this._migrationCompletedAt = completedAt; + let migratedCatalog: AutomationCatalogState; + try { + migratedCatalog = { + ...catalog, + _meta: { ...catalog._meta, [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true }, + automations: catalog.automations.map(automation => ({ + ...automation, + operations: this._migrationOperationsForItem(automation), + })), + }; + await this._persist(migratedCatalog, this._runs, this._manualRunRequests, completedAt); + } catch (error) { + this._migrationCompletedAt = priorCompletedAt; + throw error; + } + this._catalog = migratedCatalog; + this._stateManager.setAutomationCatalogState(migratedCatalog); + for (const automation of migratedCatalog.automations) { + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); + } + this._logService.info(`[AgentHostAutomationService] Automation migration completed: discovered=${expectedResources?.length ?? migratedCatalog.automations.length}, automations=${migratedCatalog.automations.length}, runs=${this._runs.size}.`); + this._recoverRuns(); + this._scheduleNext(); + }); + } + + private _migrationOperationsForItem(automation: AutomationState): AutomationOperation[] { + if (!this._canGrantRun(automation.definition)) { + // Pending imports or disabled automations must not receive Run or + // Remove: the browser scheduler still owns the legacy row until the + // pending flag clears. + return automation.operations.filter(op => op !== AutomationOperation.Run && op !== AutomationOperation.Remove); + } + return automation.runs.some(run => !isTerminalLifecycle(run.lifecycle)) + ? withOperation(automation.operations, AutomationOperation.Run).filter(operation => operation !== AutomationOperation.Remove) + : withOperation(withOperation(automation.operations, AutomationOperation.Run), AutomationOperation.Remove); + } + + private _canGrantRun(definition: AutomationDefinition): boolean { + return this._migrationCompletedAt !== undefined + && this._isAutomationsEnabled() + && !isAgentHostLegacyAutomationImportPending(definition); + } + + async handleConfigurationChanged(): Promise { + return this._enqueueMutation(async () => { + const catalog = this._requireCatalog(); + const nextCatalog: AutomationCatalogState = { + ...catalog, + automations: catalog.automations.map(automation => ({ + ...automation, + operations: this._canGrantRun(automation.definition) + ? withOperation(automation.operations, AutomationOperation.Run) + : automation.operations.filter(operation => operation !== AutomationOperation.Run + && (!isAgentHostLegacyAutomationImportPending(automation.definition) || operation !== AutomationOperation.Remove)), + })), + }; + if (!equals(nextCatalog, catalog)) { + await this._persist(nextCatalog, this._runs, this._manualRunRequests); + this._catalog = nextCatalog; + for (const automation of nextCatalog.automations) { + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); + } + } + if (this._migrationCompletedAt !== undefined && this._isAutomationsEnabled()) { + this._recoverRuns(); + this._scheduleNext(); + } else { + this._scheduleTimer.clear(); + } + }); + } + + handleAgentsChanged(): void { + if (!this._migrationCompletedAt || !this._isAutomationsEnabled()) { + return; + } + this._startPendingRuns(); + this._scheduleNext(); + } + + async handleCreate(action: AutomationCreateRequestedAction): Promise { + return this._enqueueMutation(() => this._handleCreate(action)); + } + + private async _handleCreate(action: AutomationCreateRequestedAction): Promise { + const catalog = this._requireCatalog(); + this._validateAutomationResource(action.resource); + const definition = action.definition; + this._validateDefinition(definition); + const existing = catalog.automations.find(automation => automation.resource === action.resource); + if (existing && equals(existing.definition, definition)) { + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation: existing }); + return; + } + if (existing) { + throw new Error(`Automation already exists: ${action.resource}`); + } + + const timestamp = new Date().toISOString(); + const pending = isAgentHostLegacyAutomationImportPending(definition); + const automation = this._withInitialScheduleState({ + resource: action.resource, + definition, + runs: [], + operations: [ + AutomationOperation.Update, + ...(pending ? [] : [AutomationOperation.Remove]), + ...(this._canGrantRun(definition) ? [AutomationOperation.Run] : []), + ], + createdAt: timestamp, + modifiedAt: timestamp, + }, new Date(timestamp)); + const next = automationReducer(catalog, { type: ActionType.AutomationSet, automation }, this._log); + await this._persist(next, this._runs, this._manualRunRequests); + this._catalog = next; + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); + this._scheduleNext(); + } + + async handleUpdate(action: AutomationUpdateRequestedAction): Promise { + return this._enqueueMutation(() => this._handleUpdate(action)); + } + + private async _handleUpdate(action: AutomationUpdateRequestedAction): Promise { + const catalog = this._requireCatalog(); + const existing = catalog.automations.find(automation => automation.resource === action.resource); + if (!existing) { + throw new Error(`Automation not found: ${action.resource}`); + } + this._requireOperation(existing, AutomationOperation.Update); + + let automation: AutomationState = { + ...existing, + definition: { + ...existing.definition, + ...action.changes, + }, + modifiedAt: new Date().toISOString(), + }; + this._validateDefinition(automation.definition); + if (action.changes.triggers !== undefined || action.changes.enabled !== undefined) { + automation = this._withInitialScheduleState(automation, new Date()); + } + let operations = automation.operations; + if (isAgentHostLegacyAutomationImportPending(existing.definition) + && !isAgentHostLegacyAutomationImportPending(automation.definition) + && !operations.includes(AutomationOperation.Remove)) { + // completeMigration may have stripped Remove from pending items; + // restore it now that the browser has acknowledged legacy removal. + operations = withOperation(operations, AutomationOperation.Remove); + } + if (isAgentHostLegacyAutomationImportPending(automation.definition)) { + operations = operations.filter(operation => operation !== AutomationOperation.Run && operation !== AutomationOperation.Remove); + } else if (this._canGrantRun(automation.definition)) { + operations = withOperation(operations, AutomationOperation.Run); + } else if (operations.includes(AutomationOperation.Run)) { + operations = operations.filter(op => op !== AutomationOperation.Run); + } + if (operations !== automation.operations) { + automation = { ...automation, operations }; + } + const next = automationReducer(catalog, { type: ActionType.AutomationSet, automation }, this._log); + await this._persist(next, this._runs, this._manualRunRequests); + this._catalog = next; + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); + this._scheduleNext(); + } + + async handleRemove(action: AutomationRemovedAction): Promise { + return this._enqueueMutation(() => this._handleRemove(action)); + } + + private async _handleRemove(action: AutomationRemovedAction): Promise { + const catalog = this._requireCatalog(); + const existing = catalog.automations.find(automation => automation.resource === action.resource); + if (!existing) { + return; + } + this._requireOperation(existing, AutomationOperation.Remove); + if (existing.runs.some(run => !isTerminalLifecycle(run.lifecycle))) { + throw new Error(`Automation has an active run and cannot be removed: ${action.resource}`); + } + const next = automationReducer(catalog, action, this._log); + await this._persist(next, this._runs, this._manualRunRequests); + this._catalog = next; + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, action); + this._scheduleNext(); + } + + async listTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { + this._requireAvailableCatalog(); + return { items: [] }; + } + + async runAutomation(params: RunAutomationParams): Promise { + const created = await this._enqueueMutation(() => this._createManualRun(params)); + if (created.definition) { + void this._startRun(created.run, created.definition); + } + return { resource: created.run.resource }; + } + + async fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { + return this._enqueueMutation(() => this._fetchAutomationRuns(params)); + } + + private async _fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { + const catalog = this._requireAvailableCatalog(); + const automation = catalog.automations.find(candidate => candidate.resource === params.automation); + if (!automation) { + throw new Error(`Automation not found: ${params.automation}`); + } + if (!automation.runsNextCursor) { + return {}; + } + if (params.cursor !== undefined && params.cursor !== automation.runsNextCursor) { + throw new Error(`Automation run-history cursor is no longer available: ${params.cursor}`); + } + const terminalLimit = Number(automation.runsNextCursor) + RUN_HISTORY_PAGE_SIZE; + const updated = withRunWindow(automation, this._runs, terminalLimit); + const next = automationReducer(catalog, { type: ActionType.AutomationSet, automation: updated }, this._log); + await this._persist(next, this._runs, this._manualRunRequests); + this._catalog = next; + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation: updated }); + return {}; + } + + async handleCancel(resource: string, _action: AutomationRunCancelRequestedAction): Promise { + const sessions = await this._enqueueMutation(() => this._prepareCancellation(resource)); + if (sessions.length === 0) { + return; + } + const results = await Promise.allSettled(sessions.map(session => this._execution.cancelSession(URI.parse(session)))); + let accepted = false; + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason; + } + accepted ||= result.value; + } + if (!accepted) { + const terminal = await this._enqueueMutation(async () => { + const run = this._runs.get(resource); + return run === undefined || isTerminalLifecycle(run.lifecycle); + }); + if (!terminal) { + throw new Error(`Automation run cancellation was not accepted: ${resource}`); + } + } + } + + private _load(): IStoredAutomations | undefined { + if (this._storageService.loadError) { + this._logService.error('[AgentHostAutomationService] Agent Host storage failed to load; automation state and execution remain unavailable.'); + return undefined; + } + const stored = this._storageService.get(STORAGE_KEY); + if (stored === undefined) { + return { catalog: { automations: [] } }; + } + if (!isStoredAutomations(stored)) { + this._logService.error('[AgentHostAutomationService] Automation storage is invalid; automation execution remains unavailable until it is recovered.'); + return undefined; + } + return stored; + } + + private async _persist( + catalog: AutomationCatalogState, + runs: ReadonlyMap, + manualRunRequests: ReadonlyMap, + migrationCompletedAt = this._migrationCompletedAt, + ): Promise { + await this._storageService.setAndFlush(STORAGE_KEY, { + version: 1, + catalog, + runs: [...runs.values()], + manualRunRequests: [...manualRunRequests.values()], + ...(migrationCompletedAt ? { migration: { status: 'complete', completedAt: migrationCompletedAt } } : {}), + }); + } + + private _requireCatalog(): AutomationCatalogState { + if (!this._catalog) { + throw new Error('Automation storage is unavailable and must be recovered before automations can run.'); + } + return this._catalog; + } + + private _requireAvailableCatalog(): AutomationCatalogState { + const catalog = this._requireCatalog(); + if (this._migrationCompletedAt === undefined) { + throw new Error('Automation migration must complete before automations can be accessed or run.'); + } + if (!this._isAutomationsEnabled()) { + throw new Error('Automations are disabled.'); + } + return catalog; + } + + private _isAutomationsEnabled(): boolean { + return this._stateManager.rootState.config?.values[AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY] === true; + } + + private _withInitialScheduleState(automation: AutomationState, now: Date): AutomationState { + const cursors: Record = {}; + if (automation.definition.enabled) { + for (const trigger of automation.definition.triggers) { + if (trigger.kind === AutomationTriggerKind.Schedule) { + cursors[trigger.id] = nextAutomationCronOccurrence(trigger.schedule.expression, trigger.schedule.timeZone, now).toISOString(); + } + } + } + return { + ...automation, + nextRunAt: earliestCursor(cursors), + _meta: withScheduleCursors(automation._meta, cursors), + }; + } + + private _scheduleNext(): void { + this._scheduleTimer.clear(); + if (!this._migrationCompletedAt || !this._catalog || !this._isAutomationsEnabled()) { + return; + } + const timestamps = this._catalog.automations + .filter(automation => automation.definition.enabled + && automation.operations.includes(AutomationOperation.Run) + && automation.nextRunAt + && !this._activeRunFor(automation.resource) + && this._execution.isSessionTemplateAvailable(automation.definition.session)) + .map(automation => Date.parse(automation.nextRunAt!)) + .filter(timestamp => Number.isFinite(timestamp)); + if (timestamps.length === 0) { + return; + } + const delay = Math.min(Math.max(0, Math.min(...timestamps) - Date.now()), 0x7fffffff); + this._scheduleTimer.value = disposableTimeout(() => { + void this._enqueueMutation(() => this._claimDueRuns()).then(claimed => { + this._scheduleNext(); + for (const { run, definition } of claimed) { + void this._startRun(run, definition); + } + }, error => { + this._logService.error(`[AgentHostAutomationService] Failed to claim due Automation schedules: ${toErrorMessage(error)}`); + this._scheduleTimer.value = disposableTimeout(() => this._scheduleNext(), SCHEDULE_RETRY_DELAY_MS); + }); + }, delay); + } + + private async _claimDueRuns(): Promise { + const catalog = this._requireAvailableCatalog(); + const now = new Date(); + const nowTimestamp = now.getTime(); + const createdAt = now.toISOString(); + let nextCatalog = catalog; + const nextRuns = new Map(this._runs); + const changed = new Map(); + const claimed: { run: AutomationRunState; definition: AutomationDefinition }[] = []; + + for (const current of catalog.automations) { + if (!current.definition.enabled) { + continue; + } + if (!current.operations.includes(AutomationOperation.Run)) { + continue; + } + if (this._activeRunFor(current.resource)) { + continue; + } + if (!this._execution.isSessionTemplateAvailable(current.definition.session)) { + continue; + } + const cursors = { ...readScheduleCursors(current._meta) }; + let automation = current; + let claimedForAutomation = false; + for (const trigger of current.definition.triggers) { + if (trigger.kind !== AutomationTriggerKind.Schedule) { + continue; + } + let scheduledFor = cursors[trigger.id] ? new Date(cursors[trigger.id]) : undefined; + if (!scheduledFor || !Number.isFinite(scheduledFor.getTime())) { + scheduledFor = nextAutomationCronOccurrence(trigger.schedule.expression, trigger.schedule.timeZone, now); + } else if (scheduledFor.getTime() <= nowTimestamp) { + const catchUp = nowTimestamp - scheduledFor.getTime() >= 60_000; + if (!catchUp || trigger.misfirePolicy !== AutomationMisfirePolicy.Skip) { + if (!claimedForAutomation) { + const run = this._createRunState(automation.resource, { + kind: AutomationRunOriginKind.Trigger, + triggerId: trigger.id, + scheduledFor: scheduledFor.toISOString(), + ...(catchUp ? { catchUp: true } : {}), + }, createdAt); + nextRuns.set(run.resource, run); + automation = withRunSummary(automation, nextRuns); + claimed.push({ run, definition: automation.definition }); + claimedForAutomation = true; + } + // A sibling trigger already claimed this Automation this + // tick. Coalesce this past-due firing into the earlier + // one and let its cursor roll forward below, so we don't + // re-fire on the next tick. + } + scheduledFor = nextAutomationCronOccurrence(trigger.schedule.expression, trigger.schedule.timeZone, now); + } + cursors[trigger.id] = scheduledFor.toISOString(); + } + const nextAutomation: AutomationState = { + ...automation, + nextRunAt: earliestCursor(cursors), + _meta: withScheduleCursors(automation._meta, cursors), + }; + if (!equals(nextAutomation, current)) { + nextCatalog = automationReducer(nextCatalog, { type: ActionType.AutomationSet, automation: nextAutomation }, this._log); + changed.set(nextAutomation.resource, nextAutomation); + } + } + + if (changed.size === 0) { + return []; + } + await this._persist(nextCatalog, nextRuns, this._manualRunRequests); + this._catalog = nextCatalog; + this._runs = nextRuns; + for (const run of claimed.map(entry => entry.run)) { + this._stateManager.setAutomationRunState(run); + } + for (const automation of changed.values()) { + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); + } + this._logService.info(`[AgentHostAutomationService] Claimed due Automation schedules: runs=${claimed.length}, automations=${changed.size}.`); + return claimed; + } + + private _recoverRuns(): void { + if (this._didRecoverRuns) { + return; + } + this._didRecoverRuns = true; + for (const run of this._runs.values()) { + if (run.lifecycle.status === AutomationRunStatus.Running) { + void this._enqueueMutation(() => this._failRun(run.resource, new Error('Automation execution was interrupted by an Agent Host restart.'))).catch(error => { + this._logService.error(`[AgentHostAutomationService] Failed to recover interrupted Automation run: run=${run.resource}, error=${toErrorMessage(error)}`); + }); + } + } + this._startPendingRuns(); + } + + private _startPendingRuns(): void { + for (const run of this._runs.values()) { + if (run.lifecycle.status !== AutomationRunStatus.Pending) { + continue; + } + const automation = this._catalog?.automations.find(candidate => candidate.resource === run.automation); + if (automation + && automation.operations.includes(AutomationOperation.Run) + && this._execution.isSessionTemplateAvailable(automation.definition.session)) { + void this._startRun(run, automation.definition); + } + } + } + + private async _createManualRun(params: RunAutomationParams): Promise<{ readonly run: AutomationRunState; readonly definition?: AutomationDefinition }> { + const catalog = this._requireAvailableCatalog(); + if (params.requestId.trim().length === 0) { + throw new Error('Automation run requestId must not be empty.'); + } + const previousRequest = this._manualRunRequests.get(params.requestId); + if (previousRequest) { + if (previousRequest.automation !== params.automation) { + throw new Error(`Automation run requestId is already used for another automation: ${params.requestId}`); + } + const previousRun = this._runs.get(previousRequest.run); + if (!previousRun) { + throw new Error(`Automation run requestId refers to a missing run: ${params.requestId}`); + } + return { run: previousRun }; + } + + const automation = catalog.automations.find(candidate => candidate.resource === params.automation); + if (!automation) { + throw new Error(`Automation not found: ${params.automation}`); + } + this._requireOperation(automation, AutomationOperation.Run); + const activeRun = this._activeRunFor(automation.resource); + if (activeRun) { + return { run: activeRun }; + } + const createdAt = new Date().toISOString(); + const run = this._createRunState(automation.resource, { kind: AutomationRunOriginKind.Manual }, createdAt); + const nextCatalog = this._catalogWithRun(catalog, run); + const nextRuns = new Map(this._runs); + nextRuns.set(run.resource, run); + const nextRequests = new Map(this._manualRunRequests); + nextRequests.set(params.requestId, { requestId: params.requestId, automation: params.automation, run: run.resource }); + await this._persist(nextCatalog, nextRuns, nextRequests); + this._catalog = nextCatalog; + this._runs = nextRuns; + this._manualRunRequests = nextRequests; + this._stateManager.setAutomationRunState(run); + this._publishAutomation(nextCatalog, automation.resource); + this._logService.info(`[AgentHostAutomationService] Created durable manual automation run: automation=${automation.resource}, run=${run.resource}.`); + return { run, definition: automation.definition }; + } + + private _createRunState(automation: string, origin: AutomationRunOrigin, createdAt: string): AutomationRunState { + return { + resource: URI.from({ scheme: 'ahp-automation-run', path: `/${generateUuid()}` }).toString(), + automation, + origin, + lifecycle: { status: AutomationRunStatus.Pending, createdAt }, + sessions: [], + }; + } + + private async _startRun(initialRun: AutomationRunState, definition: AutomationDefinition): Promise { + try { + if (!this._execution.isSessionTemplateAvailable(definition.session)) { + this._logService.info(`[AgentHostAutomationService] Deferring Automation run until its provider is available: run=${initialRun.resource}.`); + return; + } + const running = await this._enqueueMutation(() => this._markRunRunning(initialRun.resource)); + if (!running) { + return; + } + this._armRunTimeout(running.resource); + const session = await this._execution.createSession(definition.session, running); + const shouldStart = await this._enqueueMutation(() => this._linkRunSession(running.resource, session.toString())); + if (!shouldStart) { + await this._execution.cancelSession(session); + return; + } + await this._execution.startSession(session, definition.message); + } catch (error) { + try { + await this._enqueueMutation(() => this._failRun(initialRun.resource, error)); + } catch (persistError) { + this._logService.error(`[AgentHostAutomationService] Failed to persist automation run failure: run=${initialRun.resource}, error=${toErrorMessage(persistError)}`); + } + } + } + + private async _markRunRunning(resource: string): Promise { + const run = this._runs.get(resource); + if (!run || run.lifecycle.status !== AutomationRunStatus.Pending) { + return undefined; + } + const lifecycle: AutomationRunLifecycle = { + status: AutomationRunStatus.Running, + createdAt: run.lifecycle.createdAt, + startedAt: new Date().toISOString(), + }; + const next = { ...run, lifecycle }; + await this._commitRun(next, [{ type: ActionType.AutomationRunLifecycleChanged, lifecycle }]); + return next; + } + + private async _linkRunSession(resource: string, session: string): Promise { + const run = this._runs.get(resource); + if (!run) { + throw new Error(`Automation run not found while linking session: ${resource}`); + } + const sessions = run.sessions.includes(session) ? run.sessions : [...run.sessions, session]; + const next = { ...run, sessions, primarySession: session }; + const actions: Array = []; + if (!run.sessions.includes(session)) { + actions.push({ type: ActionType.AutomationRunSessionSet, session }); + } + if (run.primarySession !== session) { + actions.push({ type: ActionType.AutomationRunPrimarySessionChanged, primarySession: session }); + } + await this._commitRun(next, actions); + this._logService.info(`[AgentHostAutomationService] Linked automation run to session: run=${resource}, session=${session}.`); + return !isTerminalLifecycle(next.lifecycle); + } + + private async _prepareCancellation(resource: string): Promise { + this._requireAvailableCatalog(); + const run = this._runs.get(resource); + if (!run) { + throw new Error(`Automation run not found: ${resource}`); + } + if (isTerminalLifecycle(run.lifecycle)) { + throw new Error(`Automation run is already terminal: ${resource}`); + } + if (run.sessions.length > 0) { + return run.sessions; + } + const lifecycle: AutomationRunLifecycle = { + status: AutomationRunStatus.Cancelled, + createdAt: run.lifecycle.createdAt, + ...(run.lifecycle.status === AutomationRunStatus.Running ? { startedAt: run.lifecycle.startedAt } : {}), + completedAt: new Date().toISOString(), + }; + await this._commitRun({ ...run, lifecycle }, [{ type: ActionType.AutomationRunLifecycleChanged, lifecycle }]); + return []; + } + + private async _failRun(resource: string, error: unknown): Promise { + const run = this._runs.get(resource); + if (!run || isTerminalLifecycle(run.lifecycle)) { + return; + } + const lifecycle: AutomationRunLifecycle = { + status: AutomationRunStatus.Failed, + createdAt: run.lifecycle.createdAt, + ...(run.lifecycle.status === AutomationRunStatus.Running ? { startedAt: run.lifecycle.startedAt } : {}), + completedAt: new Date().toISOString(), + error: { + errorType: 'automationExecution', + message: toErrorMessage(error), + }, + }; + await this._commitRun({ ...run, lifecycle }, [{ type: ActionType.AutomationRunLifecycleChanged, lifecycle }]); + this._logService.error(`[AgentHostAutomationService] Automation run failed: run=${resource}, error=${toErrorMessage(error)}`); + } + + private _handleEnvelope(envelope: ActionEnvelope): void { + // A rejected action never reached host state, so it must not finalize a run. + if (envelope.rejectionReason) { + return; + } + if (!isDefaultChatUri(envelope.channel)) { + return; + } + const action = envelope.action; + if (action.type !== ActionType.ChatTurnComplete + && action.type !== ActionType.ChatTurnCancelled + && action.type !== ActionType.ChatError) { + return; + } + const session = parseRequiredSessionUriFromChatUri(envelope.channel); + const run = [...this._runs.values()].find(candidate => candidate.sessions.includes(session) && !isTerminalLifecycle(candidate.lifecycle)); + if (!run) { + return; + } + void this._enqueueMutation(async () => { + const current = this._runs.get(run.resource); + if (!current || isTerminalLifecycle(current.lifecycle)) { + return; + } + const completedAt = new Date().toISOString(); + let lifecycle: AutomationRunLifecycle; + switch (action.type) { + case ActionType.ChatTurnComplete: + lifecycle = { + status: AutomationRunStatus.Completed, + createdAt: current.lifecycle.createdAt, + startedAt: current.lifecycle.status === AutomationRunStatus.Running ? current.lifecycle.startedAt : completedAt, + completedAt, + }; + break; + case ActionType.ChatTurnCancelled: + lifecycle = { + status: AutomationRunStatus.Cancelled, + createdAt: current.lifecycle.createdAt, + ...(current.lifecycle.status === AutomationRunStatus.Running ? { startedAt: current.lifecycle.startedAt } : {}), + completedAt, + }; + break; + case ActionType.ChatError: + lifecycle = { + status: AutomationRunStatus.Failed, + createdAt: current.lifecycle.createdAt, + ...(current.lifecycle.status === AutomationRunStatus.Running ? { startedAt: current.lifecycle.startedAt } : {}), + completedAt, + error: action.part.error, + }; + break; + } + await this._commitRun({ ...current, lifecycle }, [{ type: ActionType.AutomationRunLifecycleChanged, lifecycle }]); + }).catch(error => this._logService.error(`[AgentHostAutomationService] Failed to persist terminal automation lifecycle: run=${run.resource}, error=${toErrorMessage(error)}`)); + } + + private async _commitRun( + run: AutomationRunState, + actions: readonly (AutomationRunLifecycleChangedAction | AutomationRunSessionSetAction | AutomationRunPrimarySessionChangedAction)[], + ): Promise { + const catalog = this._requireCatalog(); + const nextCatalog = this._catalogWithRun(catalog, run); + const nextRuns = new Map(this._runs); + nextRuns.set(run.resource, run); + await this._persist(nextCatalog, nextRuns, this._manualRunRequests); + this._catalog = nextCatalog; + this._runs = nextRuns; + for (const action of actions) { + this._stateManager.dispatchServerAction(run.resource, action); + } + this._publishAutomation(nextCatalog, run.automation); + if (isTerminalLifecycle(run.lifecycle)) { + this._runTimeouts.deleteAndDispose(run.resource); + this._scheduleNext(); + } + } + + private _catalogWithRun(catalog: AutomationCatalogState, run: AutomationRunState): AutomationCatalogState { + const existing = catalog.automations.find(automation => automation.resource === run.automation); + if (!existing) { + throw new Error(`Automation not found for run: ${run.automation}`); + } + const nextRuns = new Map(this._runs); + nextRuns.set(run.resource, run); + const automation = withRunSummary(existing, nextRuns); + return automationReducer(catalog, { type: ActionType.AutomationSet, automation }, this._log); + } + + private _publishAutomation(catalog: AutomationCatalogState, resource: string): void { + const automation = catalog.automations.find(candidate => candidate.resource === resource); + if (automation) { + this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); + } + } + + private _validateAutomationResource(resource: string): void { + if (URI.parse(resource).scheme !== 'ahp-automation') { + throw new Error(`Automation resource must use the ahp-automation scheme: ${resource}`); + } + } + + private _validateDefinition(definition: AutomationDefinition): void { + if (definition.title.trim().length === 0) { + throw new Error('Automation title must not be empty.'); + } + if (definition.message.origin.kind !== MessageKind.Automation) { + throw new Error('Automation message must have an automation origin.'); + } + const triggerIds = new Set(); + for (const trigger of definition.triggers) { + if (trigger.id.trim().length === 0 || triggerIds.has(trigger.id)) { + throw new Error(`Automation trigger ids must be non-empty and unique: ${trigger.id}`); + } + triggerIds.add(trigger.id); + if (trigger.kind === AutomationTriggerKind.Event) { + throw new Error(`Automation event trigger type is not available: ${trigger.type}`); + } + validateAutomationCron(trigger.schedule.expression, trigger.schedule.timeZone); + } + } + + private _requireOperation(automation: AutomationState, operation: AutomationOperation): void { + if (!automation.operations.includes(operation)) { + throw new Error(`Automation operation '${operation}' is not available: ${automation.resource}`); + } + } + + private _activeRunFor(automation: string): AutomationRunState | undefined { + return [...this._runs.values()].find(run => run.automation === automation && !isTerminalLifecycle(run.lifecycle)); + } + + private _armRunTimeout(resource: string): void { + const run = this._runs.get(resource); + if (!run || isTerminalLifecycle(run.lifecycle)) { + return; + } + const configured = this._stateManager.rootState.config?.values[AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY]; + const minutes = typeof configured === 'number' && Number.isFinite(configured) && configured >= 1 + ? configured + : DEFAULT_RUN_TIMEOUT_MINUTES; + this._runTimeouts.set(resource, disposableTimeout(() => { + void this.handleCancel(resource, { type: ActionType.AutomationRunCancelRequested }).catch(error => { + void this._enqueueMutation(() => this._failRun( + resource, + new Error(localize('agentHostAutomation.runTimedOut', "Automation run timed out."), { cause: error }), + )).catch(persistError => { + this._logService.error(`[AgentHostAutomationService] Failed to persist timed-out Automation run: run=${resource}, error=${toErrorMessage(persistError)}`); + }); + }); + }, minutes * 60_000)); + } + + private _enqueueMutation(mutation: () => Promise): Promise { + const next = this._mutationTail.then(mutation); + this._mutationTail = next.then(() => undefined, () => undefined); + return next; + } + + private readonly _log = (message: string) => this._logService.warn(`[AgentHostAutomationService] ${message}`); +} + +function isAutomationCatalogState(value: unknown): value is AutomationCatalogState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const automations = (value as Record)['automations']; + return Array.isArray(automations) && automations.every(isAutomationState); +} + +function isStoredAutomations(value: unknown): value is IStoredAutomations { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const stored = value as Record; + return (stored['version'] === undefined || stored['version'] === 1) + && isAutomationCatalogState(stored['catalog']) + && (stored['runs'] === undefined || Array.isArray(stored['runs']) && stored['runs'].every(isAutomationRunState)) + && (stored['manualRunRequests'] === undefined || Array.isArray(stored['manualRunRequests']) && stored['manualRunRequests'].every(isStoredManualRunRequest)) + && (stored['migration'] === undefined || isCompletedMigration(stored['migration'])); +} + +function isAutomationState(value: unknown): value is AutomationState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const record = value as Record; + return typeof record['resource'] === 'string' + && typeof record['definition'] === 'object' && record['definition'] !== null && !Array.isArray(record['definition']) + && Array.isArray(record['runs']) + && Array.isArray(record['operations']) + && typeof record['createdAt'] === 'string' + && typeof record['modifiedAt'] === 'string'; +} + +function isAutomationRunState(value: unknown): value is AutomationRunState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const run = value as Record; + return typeof run['resource'] === 'string' + && typeof run['automation'] === 'string' + && typeof run['origin'] === 'object' && run['origin'] !== null + && typeof run['lifecycle'] === 'object' && run['lifecycle'] !== null + && Array.isArray(run['sessions']) + && run['sessions'].every(session => typeof session === 'string') + && (run['primarySession'] === undefined || typeof run['primarySession'] === 'string'); +} + +function isStoredManualRunRequest(value: unknown): value is IStoredManualRunRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const request = value as Record; + return typeof request['requestId'] === 'string' + && typeof request['automation'] === 'string' + && typeof request['run'] === 'string'; +} + +function isCompletedMigration(value: unknown): value is NonNullable { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const migration = value as Record; + return migration['status'] === 'complete' && typeof migration['completedAt'] === 'string'; +} + +function toRunSummary(run: AutomationRunState): AutomationRunSummary { + return { + resource: run.resource, + automation: run.automation, + origin: run.origin, + lifecycle: run.lifecycle, + primarySession: run.primarySession, + sessionCount: run.sessions.length, + _meta: run._meta, + }; +} + +function withRunSummary(automation: AutomationState, allRuns: ReadonlyMap): AutomationState { + const terminalLimit = Math.max(RUN_HISTORY_PAGE_SIZE, automation.runs.filter(candidate => isTerminalLifecycle(candidate.lifecycle)).length); + const window = withRunWindow(automation, allRuns, terminalLimit); + const runs = window.runs; + const hasActiveRun = runs.some(candidate => !isTerminalLifecycle(candidate.lifecycle)); + return { + ...window, + operations: hasActiveRun + ? automation.operations.filter(operation => operation !== AutomationOperation.Remove) + : withOperation(automation.operations, AutomationOperation.Remove), + }; +} + +function withRunWindow(automation: AutomationState, allRuns: ReadonlyMap, terminalLimit: number): AutomationState { + const summaries = [...allRuns.values()] + .filter(run => run.automation === automation.resource) + .map(toRunSummary) + .sort((first, second) => Date.parse(second.lifecycle.createdAt) - Date.parse(first.lifecycle.createdAt)); + const active = summaries.filter(summary => !isTerminalLifecycle(summary.lifecycle)); + const terminal = summaries.filter(summary => isTerminalLifecycle(summary.lifecycle)); + const runs = [...active, ...terminal.slice(0, terminalLimit)] + .sort((first, second) => Date.parse(second.lifecycle.createdAt) - Date.parse(first.lifecycle.createdAt)); + return { + ...automation, + runs, + runsNextCursor: terminal.length > terminalLimit ? String(terminalLimit) : undefined, + }; +} + +function isTerminalLifecycle(lifecycle: AutomationRunLifecycle): boolean { + return lifecycle.status === AutomationRunStatus.Completed + || lifecycle.status === AutomationRunStatus.Failed + || lifecycle.status === AutomationRunStatus.Cancelled; +} + +function withOperation(operations: readonly AutomationOperation[], operation: AutomationOperation): AutomationOperation[] { + return operations.includes(operation) ? [...operations] : [...operations, operation]; +} + +function readScheduleCursors(meta: Record | undefined): Readonly> { + const value = meta?.[SCHEDULE_CURSORS_META_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + const cursors: Record = {}; + for (const [triggerId, cursor] of Object.entries(value)) { + if (typeof cursor === 'string') { + cursors[triggerId] = cursor; + } + } + return cursors; +} + +function withScheduleCursors(meta: Record | undefined, cursors: Readonly>): Record | undefined { + const result = { ...meta }; + if (Object.keys(cursors).length === 0) { + delete result[SCHEDULE_CURSORS_META_KEY]; + } else { + result[SCHEDULE_CURSORS_META_KEY] = cursors; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function earliestCursor(cursors: Readonly>): string | undefined { + return Object.values(cursors) + .filter(cursor => Number.isFinite(Date.parse(cursor))) + .sort((first, second) => Date.parse(first) - Date.parse(second))[0]; +} diff --git a/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts b/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts index fc8a01ad4060f6..2731fbf8f7449a 100644 --- a/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts +++ b/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts @@ -183,7 +183,9 @@ export class AgentHostCustomizationEnablementService extends Disposable implemen if (session !== undefined) { this._sessionsById.set(AgentSession.id(session), session); void this.initializeSession(session); - if (envelope.action.type === ActionType.SessionWorkingDirectorySet || envelope.action.type === ActionType.SessionWorkingDirectoryRemoved) { + if (envelope.action.type === ActionType.SessionWorkingDirectorySet + || envelope.action.type === ActionType.SessionWorkingDirectoryRemoved + || envelope.action.type === ActionType.SessionWorkingDirectoryReplaced) { const affectedSessions = this._applyPendingReplacements(session); affectedSessions.add(session); this._notifyDecisionChanged(affectedSessions); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 0793117cb25945..e6bb4493437b75 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -17,7 +17,6 @@ import * as fs from 'fs'; import * as os from 'os'; import type { Event } from '../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { raceTimeout } from '../../../base/common/async.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; @@ -30,6 +29,7 @@ import { LoggerService } from '../../log/node/loggerService.js'; import { OtlpEmitterLogger, OtlpLogEmitter } from '../common/otlp/otlpLogEmitter.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; +import { flushAgentHostPersistenceBeforeShutdown } from './agentHostShutdown.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { createAgentHostRuntime } from './agentHostBootstrap.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -388,9 +388,11 @@ async function main(): Promise { // SIGTERM arriving during a session or agent-host storage write can // drop the latest decision. // Capped so a stuck write cannot hang shutdown indefinitely. - await raceTimeout(Promise.all([sessionDataService.whenIdle(), customizationEnablementService.whenIdle()]), 3000, () => { - logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); - }); + await flushAgentHostPersistenceBeforeShutdown( + [sessionDataService.whenIdle(), customizationEnablementService.whenIdle()], + 3000, + logService, + ); disposables.dispose(); loggerService?.dispose(); process.exit(0); diff --git a/src/vs/platform/agentHost/node/agentHostShutdown.ts b/src/vs/platform/agentHost/node/agentHostShutdown.ts new file mode 100644 index 00000000000000..3c37c379f59c71 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostShutdown.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../base/common/async.js'; +import type { ILogService } from '../../log/common/log.js'; + +/** + * Flushes Agent Host persistence without allowing a failed or stalled write to + * prevent process cleanup and exit. + */ +export async function flushAgentHostPersistenceBeforeShutdown( + flushes: readonly Promise[], + timeoutMs: number, + logService: Pick, +): Promise { + try { + await raceTimeout(Promise.all(flushes), timeoutMs, () => { + logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); + }); + } catch (error) { + logService.error('[AgentHostServer] Failed to flush persistence writes during shutdown; exiting anyway.', error); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index c0791072d8c39c..e6720c8cd2f025 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -10,10 +10,10 @@ import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; +import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationCatalogState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -257,6 +257,8 @@ export class AgentHostStateManager extends Disposable { * client-dispatchable and lazily create their state on first write. */ private readonly _annotations = new Map(); + private _automationCatalog: AutomationCatalogState | undefined; + private readonly _automationRuns = new Map(); /** * Active turns per session, keyed by session URI string with the value @@ -687,6 +689,29 @@ export class AgentHostStateManager extends Disposable { }; } + if (isAhpAutomationCatalogChannel(resource)) { + if (!this._automationCatalog) { + return undefined; + } + return { + resource, + state: this._automationCatalog, + fromSeq: this._serverSeq, + }; + } + + if (isAhpAutomationRunChannel(resource)) { + const state = this._automationRuns.get(resource); + if (!state) { + return undefined; + } + return { + resource, + state, + fromSeq: this._serverSeq, + }; + } + // Changeset URIs are nested under their session URI; check them // before falling back to the session map so a session whose URI // happens to share a prefix with a changeset never collides. @@ -735,6 +760,24 @@ export class AgentHostStateManager extends Disposable { }; } + /** Installs the durable automation catalogue before accepting subscriptions. */ + setAutomationCatalogState(state: AutomationCatalogState): void { + this._automationCatalog = state; + } + + getAutomationCatalogState(): AutomationCatalogState | undefined { + return this._automationCatalog; + } + + /** Installs one durable automation run before accepting subscriptions. */ + setAutomationRunState(state: AutomationRunState): void { + this._automationRuns.set(state.resource, state); + } + + getAutomationRunState(resource: string): AutomationRunState | undefined { + return this._automationRuns.get(resource); + } + /** Read-only accessor for callers that only need to inspect a changeset (not subscribe). */ getChangesetState(changeset: URI): ChangesetState | undefined { return this._changesets.get(changeset); @@ -1565,7 +1608,7 @@ export class AgentHostStateManager extends Disposable { * The action is applied to state and emitted with the client's origin * so the originating client can reconcile. */ - dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, origin: ActionOrigin, clientContext?: IAgentHostClientTelemetryContext): unknown { + dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction, origin: ActionOrigin, clientContext?: IAgentHostClientTelemetryContext): unknown { return this._applyAndEmit(channel, action, origin, clientContext); } @@ -1743,6 +1786,32 @@ export class AgentHostStateManager extends Disposable { resultingState = newState; } + if (isAhpAutomationCatalogChannel(channel) && isAutomationAction(action)) { + const state = this._automationCatalog; + if (!state) { + this._logService.warn(`[AgentHostStateManager] Action for unavailable automation catalogue: ${channel}, type=${action.type}`); + return undefined; + } + const newState = automationReducer(state, action, this._log); + if (newState !== state) { + this._automationCatalog = newState; + } + resultingState = newState; + } + + if (isAhpAutomationRunChannel(channel) && isAutomationRunAction(action)) { + const state = this._automationRuns.get(channel); + if (!state) { + this._logService.warn(`[AgentHostStateManager] Action for unknown automation run: ${channel}, type=${action.type}`); + return undefined; + } + const newState = automationRunReducer(state, action, this._log); + if (newState !== state) { + this._automationRuns.set(channel, newState); + } + resultingState = newState; + } + // Emit envelope const envelope: ActionEnvelope = { channel, diff --git a/src/vs/platform/agentHost/node/agentHostStorageService.ts b/src/vs/platform/agentHost/node/agentHostStorageService.ts index 3ce7e4a01be38f..370ee7e775a47d 100644 --- a/src/vs/platform/agentHost/node/agentHostStorageService.ts +++ b/src/vs/platform/agentHost/node/agentHostStorageService.ts @@ -17,8 +17,10 @@ export const IAgentHostStorageService = createDecorator; + readonly loadError: Error | undefined; get(key: string): T | undefined; set(key: string, value: T): void; + setAndFlush(key: string, value: T): Promise; delete(key: string): void; whenIdle(): Promise; } @@ -46,6 +48,8 @@ export class AgentHostStorageService extends Disposable implements IAgentHostSto private readonly _writeThrottler = this._register(new Throttler()); private readonly _pendingWrites = new Set>(); private _data: Record; + private _lastWriteError: Error | undefined; + private _loadError: Error | undefined; constructor( private readonly _resource: URI | undefined, @@ -60,13 +64,39 @@ export class AgentHostStorageService extends Disposable implements IAgentHostSto return this._data[key] as T | undefined; } + get loadError(): Error | undefined { + return this._loadError; + } + set(key: string, value: T): void { + this._throwIfLoadFailed(); this._data[key] = value; this._onDidChange.fire(key); this._scheduleWrite(); } + async setAndFlush(key: string, value: T): Promise { + const hadPrevious = Object.hasOwn(this._data, key); + const previous = this._data[key]; + this.set(key, value); + try { + await this.whenIdle(); + } catch (error) { + if (this._data[key] === value) { + if (hadPrevious) { + this._data[key] = previous; + } else { + delete this._data[key]; + } + this._onDidChange.fire(key); + this._scheduleWrite(); + } + throw error; + } + } + delete(key: string): void { + this._throwIfLoadFailed(); if (!Object.hasOwn(this._data, key)) { return; } @@ -76,9 +106,13 @@ export class AgentHostStorageService extends Disposable implements IAgentHostSto } async whenIdle(): Promise { + this._throwIfLoadFailed(); while (this._pendingWrites.size > 0) { await Promise.allSettled([...this._pendingWrites]); } + if (this._lastWriteError) { + throw this._lastWriteError; + } } private _load(): Record { @@ -92,9 +126,11 @@ export class AgentHostStorageService extends Disposable implements IAgentHostSto return value as Record; } this._logService.warn(`[AgentHostStorageService] Ignoring non-object storage data: ${this._resource.toString()}`); + this._loadError = new Error(`Agent Host storage does not contain a JSON object: ${this._resource.toString()}`); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { this._logService.warn(`[AgentHostStorageService] Failed to read storage: ${this._resource.toString()}`, err); + this._loadError = err instanceof Error ? err : new Error(String(err)); } } return {}; @@ -107,15 +143,27 @@ export class AgentHostStorageService extends Disposable implements IAgentHostSto } const write = this._writeThrottler.queue(async () => { - try { - await this._writer.mkdir(dirname(resource.fsPath)); - await this._writer.writeFile(resource.fsPath, JSON.stringify(this._data)); - } catch (err) { - this._logService.error(`[AgentHostStorageService] Failed to write storage: ${resource.toString()}`, err); - } + await this._writer.mkdir(dirname(resource.fsPath)); + await this._writer.writeFile(resource.fsPath, JSON.stringify(this._data)); }); this._pendingWrites.add(write); const untrack = () => this._pendingWrites.delete(write); - write.then(untrack, untrack); + void write.then( + () => { + this._lastWriteError = undefined; + untrack(); + }, + error => { + this._lastWriteError = error instanceof Error ? error : new Error(String(error)); + this._logService.error(`[AgentHostStorageService] Failed to write storage: ${resource.toString()}`, error); + untrack(); + }, + ); + } + + private _throwIfLoadFailed(): void { + if (this._loadError) { + throw new Error('Agent Host storage is unavailable because its persisted data could not be loaded.', { cause: this._loadError }); + } } } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 3c6aee1604dc1e..fd0c962962db48 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -26,15 +26,18 @@ import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommi import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, isAgentHostAutomationMigrationCompletion } from '../common/automationMigration.js'; import { parseChangesetUri } from '../common/changesetUri.js'; -import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, isAnnotationsAction, isPassiveSessionMetadataAction, isSessionAction, type ChatAction, type IIsArchivedChangedAction, type IIsReadChangedAction, type IRootConfigChangedAction, type SessionAction, type SessionWorkingDirectoryAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, isAnnotationsAction, isPassiveSessionMetadataAction, isSessionAction, type ChatAction, type ClientAutomationAction, type ClientAutomationRunAction, type IIsArchivedChangedAction, type IIsReadChangedAction, type IRootConfigChangedAction, type SessionAction, type SessionWorkingDirectoryAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js'; import { resolveSessionWorkingDirectoryAction } from '../common/state/sessionWorkingDirectories.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js'; +import type { AutomationCapabilities } from '../common/state/protocol/common/commands.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -48,6 +51,7 @@ import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { resolveSessionRepositories } from './agentHostSessionRepositories.js'; import { findDeepestContainingWorkingDirectory, isMultiRootSession } from '../common/agentHostWorkingDirectories.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { type IAgentHostAutomationExecution, IAgentHostAutomationService } from './agentHostAutomationService.js'; import { createAgentChatContext } from './agentChatContext.js'; import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js'; import { IAgentHostDatabase } from './agentHostDatabase.js'; @@ -361,6 +365,7 @@ export interface IAgentServiceOptions { } export interface IAgentServiceCallbacks { + readonly automationExecution: IAgentHostAutomationExecution; readonly canEvictChangeset: (changeset: string) => boolean; readonly startAgentMergeTurn: IAgentMergeControllerOptions['startTurn']; readonly cancelAgentMergeTurn: IAgentMergeControllerOptions['cancelTurn']; @@ -392,6 +397,7 @@ export interface IAgentServiceCollaborators { readonly localTurns: AgentHostLocalTurns; readonly sideEffects: AgentSideEffects; readonly serverToolHost: AgentServerToolHost; + readonly automationService: IAgentHostAutomationService; } /** Core services that must exist before {@link AgentService} can be constructed. */ @@ -504,6 +510,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _serverToolHost: AgentServerToolHost; private readonly _debugLogsCollector: AgentHostDebugLogsCollector | undefined; private readonly _configurationService: AgentConfigurationService; + private readonly _automationService: IAgentHostAutomationService; /** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */ private readonly _checkpointService: IAgentHostCheckpointService; /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */ @@ -608,6 +615,7 @@ export class AgentService extends Disposable implements IAgentService { this._localTurns = collaborators.localTurns; this._sideEffects = collaborators.sideEffects; this._serverToolHost = collaborators.serverToolHost; + this._automationService = collaborators.automationService; this._register(this._providerService.registerProviderInitializer(provider => this._initializeProvider(provider))); this._register(this._providerService.onDidRegisterProvider(provider => this._onDidRegisterProvider(provider))); this._sessionResidency = this._register(instantiationService.createInstance( @@ -634,6 +642,22 @@ export class AgentService extends Disposable implements IAgentService { }, )); core.callbackBinder.bind({ + automationExecution: { + isSessionTemplateAvailable: template => this._providerService.resolveProvider(template.provider) !== undefined, + createSession: (template, run) => this.createSession({ + provider: template.provider, + model: template.model, + agent: template.agent, + workingDirectories: template.workingDirectories?.map(resource => URI.parse(resource)), + config: template.config, + _meta: { + automation: run.automation, + automationRun: run.resource, + }, + }), + startSession: (session, message) => this._startAutomationMessage(session, message), + cancelSession: session => this._cancelAutomationSession(session), + }, canEvictChangeset: changeset => this._canEvictChangeset(changeset), startAgentMergeTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), cancelAgentMergeTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), @@ -1035,9 +1059,7 @@ export class AgentService extends Disposable implements IAgentService { private _onDidRegisterProvider(provider: IAgent): void { this._registerSkillCompletionProvider(); const initialMigration = this._ensureLegacyChatsMigrated(provider); - this._initialProviderMigrations.set(provider.id, initialMigration); - void initialMigration.catch(err => - this._logService.warn(`[AgentService] registry migration: failed for late-registered provider ${provider.id}`, err)); + this._trackInitialProviderMigration(provider, initialMigration); // Persisted enablement must resume without a client opening the session. this._agentMergeRestore = this._agentMergeRestore .then(() => initialMigration) @@ -1045,6 +1067,14 @@ export class AgentService extends Disposable implements IAgentService { .catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err)); } + private _trackInitialProviderMigration(provider: IAgent, migration: Promise): Promise { + this._initialProviderMigrations.set(provider.id, migration); + void migration + .then(() => this._automationService.handleAgentsChanged()) + .catch(err => this._logService.warn(`[AgentService] provider initialization failed before Automations could refresh for ${provider.id}`, err)); + return migration; + } + private _registerSkillCompletionProvider(): void { if (this._skillCompletionProviderRegistered) { return; @@ -1174,16 +1204,39 @@ export class AgentService extends Disposable implements IAgentService { * client-initiated turn takes (which sends the message to the provider). */ private async _startSessionPrompt(session: URI, chat: URI, prompt: string, delegation?: IAgentMessageDelegationMeta): Promise { + // The calling agent authored this prompt, not the user. const message: Message = { text: prompt, origin: { kind: MessageKind.Agent }, ...(delegation ? { _meta: toAgentMessageDelegationMeta(delegation) } : {}), }; + await this._startSessionMessage(chat, message); + } + + private async _startAutomationMessage(session: URI, message: Message): Promise { + await this._startSessionMessage(URI.parse(buildDefaultChatUri(session)), message); + } + + private async _startSessionMessage(chat: URI, message: Message): Promise { const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const; this._stateManager.dispatchServerAction(chat.toString(), action); this._sideEffects.handleAction(chat.toString(), action); } + private async _cancelAutomationSession(session: URI): Promise { + const chat = buildDefaultChatUri(session); + const activeTurn = this._stateManager.getChatState(chat)?.activeTurn; + if (!activeTurn) { + return false; + } + const startedAt = Date.parse(activeTurn.startedAt); + const duration = Number.isFinite(startedAt) ? Math.max(0, Date.now() - startedAt) : 0; + const action = { type: ActionType.ChatTurnCancelled, turnId: activeTurn.id, duration } as const; + this._stateManager.dispatchServerAction(chat, action); + this._sideEffects.handleAction(chat, action); + return true; + } + private _startAgentMergePrompt(session: string, turnId: string, prompt: string): boolean { if (this._stateManager.hasActiveTurn(session)) { return false; @@ -1583,8 +1636,7 @@ export class AgentService extends Disposable implements IAgentService { return current ?? Promise.resolve(); } const retry = this._ensureLegacyChatsMigrated(provider, true); - this._initialProviderMigrations.set(provider.id, retry); - return retry; + return this._trackInitialProviderMigration(provider, retry); } /** @@ -3819,6 +3871,22 @@ export class AgentService extends Disposable implements IAgentService { return this._completions.completions(params); } + get automationCapabilities(): AutomationCapabilities | undefined { + return this._automationService.capabilities; + } + + async listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise { + return this._automationService.listTriggerDefinitions(params); + } + + async runAutomation(params: RunAutomationParams): Promise { + return this._automationService.runAutomation(params); + } + + async fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { + return this._automationService.fetchAutomationRuns(params); + } + async getCompletionTriggerCharacters(): Promise { return this._completions.triggerCharacters; } @@ -4299,11 +4367,62 @@ export class AgentService extends Disposable implements IAgentService { return true; } - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void { + private _isAutomationAction(action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction): action is ClientAutomationAction { + return action.type === ActionType.AutomationCreateRequested + || action.type === ActionType.AutomationUpdateRequested + || action.type === ActionType.AutomationRemoved; + } + + private _isAutomationRunAction(action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction): action is ClientAutomationRunAction { + return action.type === ActionType.AutomationRunCancelRequested; + } + + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void { const clientContext = typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); + if (action.type === ActionType.RootConfigChanged && Object.hasOwn(action.config, AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY)) { + const migration = action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY]; + const origin = { clientId, clientSeq }; + if (!isAgentHostAutomationMigrationCompletion(migration)) { + this._stateManager.rejectClientAction(channel, action, origin, 'Invalid automation migration completion payload.'); + return; + } + if (Object.keys(action.config).length !== 1 || action.replace) { + this._stateManager.rejectClientAction(channel, action, origin, 'Automation migration completion must be dispatched as an isolated root-config patch.'); + return; + } + this._dispatchAutomationMigrationAction(channel, action, clientId, clientSeq, clientContext); + return; + } + if (this._isAutomationAction(action)) { + const origin = { clientId, clientSeq }; + if (channel !== AUTOMATION_CATALOG_URI) { + this._stateManager.rejectClientAction(channel, action, origin, 'Automation actions require the automation catalogue channel.'); + return; + } + + void this._dispatchAutomationAction(action).catch(error => { + const message = toErrorMessage(error); + this._logService.error(`[AgentService] automation action failed: ${message}`); + this._stateManager.rejectClientAction(channel, action, origin, message); + }); + return; + } + if (this._isAutomationRunAction(action)) { + const origin = { clientId, clientSeq }; + if (!isAhpAutomationRunChannel(channel)) { + this._stateManager.rejectClientAction(channel, action, origin, 'Automation run actions require an automation-run channel.'); + return; + } + void this._automationService.handleCancel(channel, action).catch(error => { + const message = toErrorMessage(error); + this._logService.error(`[AgentService] automation run action failed: ${message}`); + this._stateManager.rejectClientAction(channel, action, origin, message); + }); + return; + } // Clients dispatch chat (chat) actions against a chat channel // URI. Keep that chat channel for the optimistic state apply and for @@ -4323,6 +4442,7 @@ export class AgentService extends Disposable implements IAgentService { this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientContext); return; } + const next = (pending ?? Promise.resolve()).then(async () => { const sessionUri = URI.parse(sessionChannel); const subagent = parseSubagentSessionUri(sessionUri); @@ -4380,6 +4500,38 @@ export class AgentService extends Disposable implements IAgentService { this._clientDispatchQueues.set(clientId, next); } + private _dispatchAutomationMigrationAction(channel: string, action: IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { + const pending = this._clientDispatchQueues.get(clientId); + const next = (pending ?? Promise.resolve()).then(async () => { + const migration = action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY]; + if (!isAgentHostAutomationMigrationCompletion(migration)) { + throw new Error('Invalid automation migration completion payload.'); + } + await this._automationService.completeMigration(migration.resources); + this._dispatchActionNow(channel, channel, action, clientId, clientSeq, clientContext); + }).catch(error => { + const message = toErrorMessage(error); + this._logService.error(`[AgentService] Failed to complete automation migration: ${message}`); + this._stateManager.rejectClientAction(channel, action, { clientId, clientSeq }, message); + }).finally(() => { + if (this._clientDispatchQueues.get(clientId) === next) { + this._clientDispatchQueues.delete(clientId); + } + }); + this._clientDispatchQueues.set(clientId, next); + } + + private async _dispatchAutomationAction(action: ClientAutomationAction): Promise { + switch (action.type) { + case ActionType.AutomationCreateRequested: + return this._automationService.handleCreate(action); + case ActionType.AutomationUpdateRequested: + return this._automationService.handleUpdate(action); + case ActionType.AutomationRemoved: + return this._automationService.handleRemove(action); + } + } + /** * Authoritative gate for every client working-directory action. Throws when * the session or its provider cannot accept the change — including a removal @@ -4407,7 +4559,10 @@ export class AgentService extends Disposable implements IAgentService { throw new Error(`Provider does not support dynamic working-directory changes: ${AgentSession.provider(sessionUri) ?? '(unknown)'}`); } - return resolveSessionWorkingDirectoryAction(action, state.workingDirectories, capability.immutablePrimary === true); + return resolveSessionWorkingDirectoryAction(action, state.workingDirectories, { + immutablePrimary: capability.immutablePrimary === true, + primaryReplacement: capability.primaryReplacement === true, + }); } /** @@ -4494,7 +4649,8 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.rejectClientAction(channel, action, origin, 'Session working-directory replacement is not supported.'); return; } - if (action.type === ActionType.SessionWorkingDirectorySet || action.type === ActionType.SessionWorkingDirectoryRemoved) { + if (action.type === ActionType.SessionWorkingDirectorySet + || action.type === ActionType.SessionWorkingDirectoryRemoved) { if (clientContext.clientType !== AgentHostClientType.EditorWindow) { this._stateManager.rejectClientAction(channel, action, origin, 'Session working-directory actions require an Editor Window client.'); return; @@ -4510,6 +4666,13 @@ export class AgentService extends Disposable implements IAgentService { return; } } + const automationMigration = action.type === ActionType.RootConfigChanged + ? action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY] + : undefined; + if (automationMigration !== undefined && !isAgentHostAutomationMigrationCompletion(automationMigration)) { + this._stateManager.rejectClientAction(channel, action, origin, 'Invalid automation migration completion payload.'); + return; + } this._stateManager.dispatchClientAction(channel, action, origin, clientContext); if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); @@ -4517,6 +4680,9 @@ export class AgentService extends Disposable implements IAgentService { if (typeof editTelemetryEnabled === 'boolean') { this._editAttributionService.setEnabled(editTelemetryEnabled); } + void this._automationService.handleConfigurationChanged().catch(error => { + this._logService.error(`[AgentService] Failed to apply Automation configuration: ${toErrorMessage(error)}`); + }); } this._sideEffects.handleAction(channel, action, clientId, clientContext, resumedTurn); } diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 8ec82eb9b8f370..61317402fe8fe2 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -19,6 +19,7 @@ import type { IAgent } from '../common/agent.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { AgentHostAutomationService } from './agentHostAutomationService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; @@ -142,6 +143,7 @@ export function createAgentServiceComposition( buildServerToolGroups(callbackAdapter.sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), ); + const automationService = owned.add(instantiationService.createInstance(AgentHostAutomationService, callbackAdapter.automationExecution)); const collaborators: IAgentServiceCollaborators = { gitHubEndpointService, gitStateService, @@ -156,6 +158,7 @@ export function createAgentServiceComposition( localTurns, sideEffects, serverToolHost, + automationService, }; agentService = instantiationService.createInstance(AgentService, core, collaborators, options); for (const disposable of additionalDisposables) { diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index ab118cb1468b9d..2a4f02b6989b60 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -13,6 +13,7 @@ import { IRequestService } from '../../request/common/request.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { AgentHostProxyConfigKey } from '../common/agentHostSchema.js'; import type { IAgentServiceCallbacks, IAgentServiceCallbackBinder } from './agentService.js'; +import type { IAgentHostAutomationExecution } from './agentHostAutomationService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostAuthenticationService, IAgentHostAuthenticationController, IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; @@ -26,6 +27,13 @@ import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder { private callbacks: IAgentServiceCallbacks | undefined; + readonly automationExecution: IAgentHostAutomationExecution = { + isSessionTemplateAvailable: template => this.value.automationExecution.isSessionTemplateAvailable(template), + createSession: (template, run) => this.value.automationExecution.createSession(template, run), + startSession: (session, message) => this.value.automationExecution.startSession(session, message), + cancelSession: session => this.value.automationExecution.cancelSession(session), + }; + readonly sessionServerToolAccessor: ISessionServerToolAccessor = { isActiveAgentTitleGenerationEnabled: () => this.value.sessionServerToolAccessor.isActiveAgentTitleGenerationEnabled(), listSessions: () => this.value.sessionServerToolAccessor.listSessions(), diff --git a/src/vs/platform/agentHost/node/automationCron.ts b/src/vs/platform/agentHost/node/automationCron.ts new file mode 100644 index 00000000000000..3f5bea84a97534 --- /dev/null +++ b/src/vs/platform/agentHost/node/automationCron.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const MINUTE_MS = 60_000; +const DATE_SEARCH_STEP_MS = 12 * 60 * MINUTE_MS; +const MAX_SEARCH_MS = 10 * 366 * 24 * 60 * MINUTE_MS; + +const MONTH_NAMES = new Map([ + ['JAN', 1], + ['FEB', 2], + ['MAR', 3], + ['APR', 4], + ['MAY', 5], + ['JUN', 6], + ['JUL', 7], + ['AUG', 8], + ['SEP', 9], + ['OCT', 10], + ['NOV', 11], + ['DEC', 12], +]); + +const WEEKDAY_NAMES = new Map([ + ['SUN', 0], + ['MON', 1], + ['TUE', 2], + ['WED', 3], + ['THU', 4], + ['FRI', 5], + ['SAT', 6], +]); + +interface ICronField { + readonly values: ReadonlySet; + readonly unrestricted: boolean; +} + +interface IAutomationCron { + readonly minute: ICronField; + readonly hour: ICronField; + readonly dayOfMonth: ICronField; + readonly month: ICronField; + readonly dayOfWeek: ICronField; +} + +interface ILocalDateParts { + readonly minute: number; + readonly hour: number; + readonly dayOfMonth: number; + readonly month: number; + readonly dayOfWeek: number; +} + +export function validateAutomationCron(expression: string, timeZone: string): void { + parseAutomationCron(expression); + createDateFormatter(timeZone); +} + +export function nextAutomationCronOccurrence(expression: string, timeZone: string, after: Date): Date { + const cron = parseAutomationCron(expression); + const formatter = createDateFormatter(timeZone); + let candidate = Math.floor(after.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS; + const searchEnd = candidate + MAX_SEARCH_MS; + let minuteSearchEnd = candidate; + while (candidate < searchEnd) { + const parts = readLocalDateParts(formatter, candidate); + const dateMatches = matchesDate(cron, parts); + if (dateMatches && matchesTime(cron, parts)) { + return new Date(candidate); + } + if (dateMatches || candidate < minuteSearchEnd) { + candidate += MINUTE_MS; + continue; + } + + // Backfill the coarse interval when it enters an eligible local date so its earliest minutes are not skipped. + const jumpedCandidate = Math.min(candidate + DATE_SEARCH_STEP_MS, searchEnd); + const jumpedParts = readLocalDateParts(formatter, jumpedCandidate); + if (matchesDate(cron, jumpedParts)) { + minuteSearchEnd = jumpedCandidate; + candidate += MINUTE_MS; + } else { + candidate = jumpedCandidate; + } + } + throw new Error(`Automation schedule has no occurrence within ten years: ${expression}`); +} + +function parseAutomationCron(expression: string): IAutomationCron { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) { + throw new Error(`Automation schedule must contain exactly five fields: ${expression}`); + } + const cron: IAutomationCron = { + minute: parseField(fields[0], 0, 59), + hour: parseField(fields[1], 0, 23), + dayOfMonth: parseField(fields[2], 1, 31), + month: parseField(fields[3], 1, 12, MONTH_NAMES), + dayOfWeek: parseField(fields[4], 0, 7, WEEKDAY_NAMES, value => value === 7 ? 0 : value), + }; + if (!hasPossibleCalendarDay(cron)) { + throw new Error(`Automation schedule cannot match a real calendar date: ${expression}`); + } + return cron; +} + +function hasPossibleCalendarDay(cron: IAutomationCron): boolean { + if (cron.dayOfMonth.unrestricted || !cron.dayOfWeek.unrestricted) { + return true; + } + const maximumDayByMonth = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for (const month of cron.month.values) { + const maximumDay = maximumDayByMonth[month]; + for (const day of cron.dayOfMonth.values) { + if (day <= maximumDay) { + return true; + } + } + } + return false; +} + +function parseField( + field: string, + minimum: number, + maximum: number, + names: ReadonlyMap = new Map(), + normalize: (value: number) => number = value => value, +): ICronField { + const values = new Set(); + for (const segment of field.split(',')) { + if (!segment) { + throw new Error(`Automation schedule contains an empty field segment: ${field}`); + } + const stepParts = segment.split('/'); + if (stepParts.length > 2) { + throw new Error(`Automation schedule contains an invalid step: ${segment}`); + } + const step = stepParts[1] === undefined ? 1 : parsePositiveInteger(stepParts[1], segment); + const base = stepParts[0]; + let start: number; + let end: number; + if (base === '*') { + start = minimum; + end = maximum; + } else { + const range = base.split('-'); + if (range.length === 1) { + if (stepParts.length > 1) { + throw new Error(`Automation schedule steps require '*' or a range: ${segment}`); + } + start = parseValue(range[0], minimum, maximum, names); + end = start; + } else if (range.length === 2) { + start = parseValue(range[0], minimum, maximum, names); + end = parseValue(range[1], minimum, maximum, names); + if (start > end) { + throw new Error(`Automation schedule ranges must be ascending: ${segment}`); + } + } else { + throw new Error(`Automation schedule contains an invalid range: ${segment}`); + } + } + for (let value = start; value <= end; value += step) { + values.add(normalize(value)); + } + } + return { values, unrestricted: field === '*' }; +} + +function parseValue(value: string, minimum: number, maximum: number, names: ReadonlyMap): number { + const named = names.get(value.toUpperCase()); + const parsed = named ?? (/^\d+$/.test(value) ? Number(value) : Number.NaN); + if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`Automation schedule value is outside ${minimum}-${maximum}: ${value}`); + } + return parsed; +} + +function parsePositiveInteger(value: string, segment: string): number { + const parsed = /^\d+$/.test(value) ? Number(value) : Number.NaN; + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Automation schedule step must be a positive integer: ${segment}`); + } + return parsed; +} + +function createDateFormatter(timeZone: string): Intl.DateTimeFormat { + try { + return new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + weekday: 'short', + hourCycle: 'h23', + }); + } catch (error) { + throw new Error(`Automation schedule uses an invalid time zone: ${timeZone}`, { cause: error }); + } +} + +function readLocalDateParts(formatter: Intl.DateTimeFormat, timestamp: number): ILocalDateParts { + const parts = new Map(formatter.formatToParts(timestamp).map(part => [part.type, part.value])); + const weekday = WEEKDAY_NAMES.get((parts.get('weekday') ?? '').toUpperCase()); + if (weekday === undefined) { + throw new Error('Automation schedule could not resolve the local weekday.'); + } + return { + minute: Number(parts.get('minute')), + hour: Number(parts.get('hour')), + dayOfMonth: Number(parts.get('day')), + month: Number(parts.get('month')), + dayOfWeek: weekday, + }; +} + +function matchesDate(cron: IAutomationCron, parts: ILocalDateParts): boolean { + if (!cron.month.values.has(parts.month)) { + return false; + } + const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth); + const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek); + if (cron.dayOfMonth.unrestricted) { + return cron.dayOfWeek.unrestricted || dayOfWeekMatches; + } + if (cron.dayOfWeek.unrestricted) { + return dayOfMonthMatches; + } + return dayOfMonthMatches || dayOfWeekMatches; +} + +function matchesTime(cron: IAutomationCron, parts: ILocalDateParts): boolean { + return cron.minute.values.has(parts.minute) && cron.hour.values.has(parts.hour); +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index cb3f1ec28b38c4..16d9b2a0a51d6c 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -24,7 +24,7 @@ import { collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExte import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; -import { ActionEnvelope, ActionType, INotification, isAnnotationsAction, isChangesetAction, isChatAction, isSessionAction, isTerminalAction, type ChatAction, type ClientAnnotationsAction, type ClientChangesetAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; +import { ActionEnvelope, ActionType, INotification, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isChangesetAction, isChatAction, isSessionAction, isTerminalAction, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { negotiateProtocolVersion } from '../common/state/protocol/version/negotiation.js'; import { VSCODE_UPGRADE_METHOD, type UnsupportedProtocolVersionErrorDataEx } from '../common/state/protocolUpgrade.js'; @@ -49,7 +49,7 @@ import { type SubscribeResult, type ListSessionsResult, } from '../common/state/sessionProtocol.js'; -import { isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; @@ -501,7 +501,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien case 'dispatchAction': if (client) { this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); - const action = msg.params.action as SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction; + const action = msg.params.action as SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction; const channel = msg.params.channel; // Unsupported actions are echoed as rejections so optimistic clients roll back. if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) { @@ -512,7 +512,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien { clientId: client.clientId, clientSeq: msg.params.clientSeq }, `Unsupported action: ${action.type}`, ); - } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || isChangesetAction(action) || isAnnotationsAction(action) || action.type === ActionType.RootConfigChanged) { + } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || isChangesetAction(action) || isAnnotationsAction(action) || isAutomationAction(action) || isAutomationRunAction(action) || action.type === ActionType.RootConfigChanged) { this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq, client.telemetryContext); } } @@ -656,6 +656,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien completionTriggerCharacters: this._config.completionTriggerCharacters ? [...this._config.completionTriggerCharacters] : undefined, terminalCommandPrefix: this._config.terminalCommandPrefix, telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined, + automations: this._agentService.automationCapabilities, }, }; } catch (error) { @@ -700,6 +701,21 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return snapshot; } + private async _subscribeStateChannel(channel: string, clientId: string, isActive?: () => boolean): Promise { + if (!isAhpAutomationCatalogChannel(channel)) { + return this._agentService.subscribe(URI.parse(channel), clientId, isActive); + } + if (isActive && !isActive()) { + throw new Error(`Subscription cancelled: ${channel}`); + } + const snapshot = this._stateManager.getSnapshot(channel); + if (!snapshot) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Automation catalogue is unavailable: ${channel}`); + } + this._agentService.addSubscriber(URI.parse(channel), clientId); + return snapshot; + } + /** * Forwards a client's upgrade request to the hosting VS Code CLI's * HTTP management API (advertised via the {@link VSCODE_AGENT_HOST_MANAGEMENT_SOCKET_ENV}). @@ -883,8 +899,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien pendingSubscriptions.push({ pending: pendingSubscription, active: classified }); client.subscriptions.set(classified.uri, pendingSubscription); try { - const snapshot = await this._agentService.subscribe( - URI.parse(key), + const snapshot = await this._subscribeStateChannel( + key, client.clientId, () => client.subscriptions.get(classified.uri) === pendingSubscription, ); @@ -1400,8 +1416,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien : { ...classified, active: false }; client.subscriptions.set(classified.uri, pendingSubscription); try { - const snapshot = await this._agentService.subscribe( - URI.parse(params.channel), + const snapshot = await this._subscribeStateChannel( + params.channel, client.clientId, () => client.subscriptions.get(classified.uri) === pendingSubscription, ); @@ -1532,6 +1548,15 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien }); return { items: this._stateManager.prepareSessionSummariesForListing(items) }; }, + listAutomationTriggerDefinitions: async (_client, params) => { + return this._agentService.listAutomationTriggerDefinitions(params); + }, + runAutomation: async (_client, params) => { + return this._agentService.runAutomation(params); + }, + fetchAutomationRuns: async (_client, params) => { + return this._agentService.fetchAutomationRuns(params); + }, resolveSessionConfig: async (_client, params) => { return this._agentService.resolveSessionConfig({ provider: params.provider, @@ -1613,18 +1638,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien invokeChangesetOperation: async (_client, params) => { return this._agentService.invokeChangesetOperation(params); }, - // Automations are declared by the protocol but not implemented by this - // host: `initialize` never advertises the `automations` capability, so - // a conforming client does not reach these methods. - listAutomationTriggerDefinitions: async () => { - throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Automations are not supported by this agent host'); - }, - runAutomation: async () => { - throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Automations are not supported by this agent host'); - }, - fetchAutomationRuns: async () => { - throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Automations are not supported by this agent host'); - }, }; diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index db8381669620c0..4207edea9d1c49 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -4,14 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ActionType, type ActionEnvelope, type ClientChangesetAction } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type ChangesetState, type ErrorInfo, type RootState, type SessionState, type SessionSummary, type TerminalState, type Turn } from '../../common/state/protocol/state.js'; -import { buildDefaultChatUri, createChatState, createDefaultChatSummary, getTurnError, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; -import { AgentSubscriptionManager, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; +import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, ChangesetStatus, MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type AutomationCatalogState, type AutomationRunState, type ChangesetState, type ErrorInfo, type RootState, type SessionState, type SessionSummary, type TerminalState, type Turn } from '../../common/state/protocol/state.js'; +import { AUTOMATION_CATALOG_URI, buildDefaultChatUri, createChatState, createDefaultChatSummary, getTurnError, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; +import { AgentSubscriptionManager, AutomationCatalogSubscription, AutomationRunSubscription, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; import { normalizeLegacyActionEnvelope, readLegacyTurnError } from '../../common/state/legacyProtocolCompatibility.js'; // Helpers @@ -83,6 +84,121 @@ const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toStri const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: '/term1' }).toString(); const chatUri = buildDefaultChatUri(sessionUri); const changesetUri = `${sessionUri}/changeset/session`; +const automationUri = 'ahp-automation:/test-automation'; +const automationRunUri = 'ahp-automation-run:/test-run'; + +function makeAutomationCatalogState(): AutomationCatalogState { + return { automations: [] }; +} + +function makeAutomationRunState(): AutomationRunState { + return { + resource: automationRunUri, + automation: automationUri, + origin: { kind: AutomationRunOriginKind.Manual }, + lifecycle: { status: AutomationRunStatus.Pending, createdAt: new Date(1).toISOString() }, + sessions: [], + }; +} + +suite('Automation subscriptions', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('catalogue changes only after the authoritative set action', () => { + const subscription = disposables.add(new AutomationCatalogSubscription('c1', noop)); + subscription.handleSnapshot(makeAutomationCatalogState(), 0); + const definition = { + title: 'Daily summary', + message: { text: 'Summarize the repository.', origin: { kind: MessageKind.Automation } }, + session: {}, + enabled: true, + triggers: [], + }; + + subscription.receiveEnvelope(makeEnvelope({ + type: ActionType.AutomationCreateRequested, + resource: automationUri, + definition, + }, 1, { clientId: 'c1', clientSeq: 1 }, undefined, AUTOMATION_CATALOG_URI)); + + const requested = subscription.value as AutomationCatalogState; + subscription.receiveEnvelope(makeEnvelope({ + type: ActionType.AutomationSet, + automation: { + resource: automationUri, + definition, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + }, + }, 2, undefined, undefined, AUTOMATION_CATALOG_URI)); + + assert.deepStrictEqual({ + requested: requested.automations, + authoritative: (subscription.value as AutomationCatalogState).automations.map(automation => automation.resource), + }, { + requested: [], + authoritative: [automationUri], + }); + }); + + test('run cancellation remains side-effect-only until the lifecycle changes', () => { + const subscription = disposables.add(new AutomationRunSubscription(automationRunUri, 'c1', noop)); + subscription.handleSnapshot(makeAutomationRunState(), 0); + subscription.receiveEnvelope(makeEnvelope({ + type: ActionType.AutomationRunCancelRequested, + }, 1, { clientId: 'c1', clientSeq: 1 }, undefined, automationRunUri)); + const requested = (subscription.value as AutomationRunState).lifecycle.status; + subscription.receiveEnvelope(makeEnvelope({ + type: ActionType.AutomationRunLifecycleChanged, + lifecycle: { + status: AutomationRunStatus.Cancelled, + createdAt: new Date(1).toISOString(), + completedAt: new Date(2).toISOString(), + }, + }, 2, undefined, undefined, automationRunUri)); + + assert.deepStrictEqual({ + requested, + authoritative: (subscription.value as AutomationRunState).lifecycle.status, + }, { + requested: AutomationRunStatus.Pending, + authoritative: AutomationRunStatus.Cancelled, + }); + }); + + test('rejected removal does not mutate the catalogue', () => { + const subscription = disposables.add(new AutomationCatalogSubscription('c1', noop)); + const definition = { + title: 'Daily summary', + message: { text: 'Summarize the repository.', origin: { kind: MessageKind.Automation } }, + session: {}, + enabled: true, + triggers: [], + }; + subscription.handleSnapshot({ + automations: [{ + resource: automationUri, + definition, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + }], + }, 0); + + subscription.receiveEnvelope(makeEnvelope({ + type: ActionType.AutomationRemoved, + resource: automationUri, + }, 1, { clientId: 'c1', clientSeq: 1 }, 'Automation has an active run.', AUTOMATION_CATALOG_URI)); + + assert.deepStrictEqual( + (subscription.value as AutomationCatalogState).automations.map(automation => automation.resource), + [automationUri], + ); + }); +}); suite('ChangesetStateSubscription', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -736,9 +852,9 @@ suite('AgentSubscriptionManager', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState | ChangesetState | AnnotationsState; fromSeq: number }> = async (resource) => { - subscribedResources.push(resource.toString()); + function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState | ChangesetState | AnnotationsState | AutomationCatalogState; fromSeq: number }> = async resource => { const key = resource.toString(); + subscribedResources.push(key); if (key.endsWith('/annotations')) { return { resource: key, state: { annotations: [] }, fromSeq: 0 }; } @@ -752,7 +868,7 @@ suite('AgentSubscriptionManager', () => { () => ++seq, noop, subscribe, - (resource) => { + resource => { unsubscribedResources.push(resource.toString()); }, )); @@ -887,6 +1003,28 @@ suite('AgentSubscriptionManager', () => { ref.dispose(); }); + test('preserves the exact authority-less automation catalogue channel', async () => { + const mgr = createManager(async resource => { + subscribedResources.push(resource.toString()); + return { resource: resource.toString(), state: { automations: [] }, fromSeq: 0 }; + }); + const ref = mgr.getSubscriptionByChannel(StateComponents.AutomationCatalog, AUTOMATION_CATALOG_URI, 'AutomationHolder'); + await Event.toPromise(ref.object.onDidChange); + + assert.deepStrictEqual({ + subscribedResources, + channels: mgr.currentSubscriptionChannels(), + activeChannel: mgr.getActiveSubscriptions()[0].channel, + }, { + subscribedResources: [AUTOMATION_CATALOG_URI], + channels: [AUTOMATION_CATALOG_URI], + activeChannel: AUTOMATION_CATALOG_URI, + }); + + ref.dispose(); + assert.deepStrictEqual(unsubscribedResources, [AUTOMATION_CATALOG_URI]); + }); + test('dispatchOptimistic applies to matching session subscription', async () => { const mgr = createManager(); const uri = URI.parse(sessionUri); @@ -1124,13 +1262,34 @@ suite('AgentSubscriptionManager', () => { mgr.dispatchOptimistic(sessionUri, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///ws2' }); - mgr.markSubscriptionsMissing([URI.parse(sessionUri)]); + mgr.markSubscriptionsMissing([sessionUri]); assert.ok(ref.object.value instanceof Error); assert.deepStrictEqual(mgr.getPendingActions(), []); ref.dispose(); }); + test('markSubscriptionsMissing preserves exact protocol channels', async () => { + const mgr = createManager(async resource => ({ + resource: resource.toString(), + state: { automations: [] }, + fromSeq: 0, + })); + const ref = mgr.getSubscriptionByChannel(StateComponents.AutomationCatalog, AUTOMATION_CATALOG_URI, 'test'); + await Event.toPromise(ref.object.onDidChange); + + mgr.markSubscriptionsMissing([AUTOMATION_CATALOG_URI]); + + assert.deepStrictEqual({ + valueIsError: ref.object.value instanceof Error, + channel: mgr.getActiveSubscriptions()[0].channel, + }, { + valueIsError: true, + channel: AUTOMATION_CATALOG_URI, + }); + ref.dispose(); + }); + test('fresh reconnect snapshots preserve pending annotation actions for replay', async () => { const mgr = createManager(); const annotationsUri = buildAnnotationsUri(sessionUri); diff --git a/src/vs/platform/agentHost/test/common/sessionWorkingDirectories.test.ts b/src/vs/platform/agentHost/test/common/sessionWorkingDirectories.test.ts index 363acb811e0b07..6c6510a229876c 100644 --- a/src/vs/platform/agentHost/test/common/sessionWorkingDirectories.test.ts +++ b/src/vs/platform/agentHost/test/common/sessionWorkingDirectories.test.ts @@ -14,6 +14,11 @@ suite('Session working directories', () => { const primary = 'file:///workspace/primary'; const secondary = 'file:///workspace/secondary'; + const replacement = 'file:///workspace/replacement'; + const capImmutable = { immutablePrimary: true, primaryReplacement: false }; + const capReplaceablePrimary = { immutablePrimary: true, primaryReplacement: true }; + const capReplaceablePrimaryOnly = { immutablePrimary: false, primaryReplacement: true }; + const capNone = { immutablePrimary: false, primaryReplacement: false }; test('compares additional working directories as an unordered set', () => { const first = [URI.file('/workspace/a'), URI.file('/workspace/b')]; @@ -51,12 +56,12 @@ suite('Session working directories', () => { resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectorySet, directory: encodedEquivalent }, [primary, secondary], - true, + capImmutable, ), resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectoryRemoved, directory: encodedEquivalent }, [primary, secondary], - true, + capImmutable, ), ], [ { type: ActionType.SessionWorkingDirectorySet, directory: secondary }, @@ -69,12 +74,12 @@ suite('Session working directories', () => { resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///workspace/%61dded' }, [primary, secondary], - true, + capImmutable, ), resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectoryRemoved, directory: 'file:///workspace/%61bsent' }, [primary, secondary], - true, + capImmutable, ), ], [ { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///workspace/added' }, @@ -87,7 +92,18 @@ suite('Session working directories', () => { () => resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectoryRemoved, directory: 'file:///workspace/%70rimary' }, [primary, secondary], - true, + capImmutable, + ), + /The primary working directory cannot be removed/, + ); + }); + + test('rejects removal of index zero when the provider advertises primaryReplacement without immutablePrimary', () => { + assert.throws( + () => resolveSessionWorkingDirectoryAction( + { type: ActionType.SessionWorkingDirectoryRemoved, directory: primary }, + [primary, secondary], + capReplaceablePrimaryOnly, ), /The primary working directory cannot be removed/, ); @@ -98,7 +114,7 @@ suite('Session working directories', () => { resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectoryRemoved, directory: primary }, [primary, secondary], - false, + capNone, ), { type: ActionType.SessionWorkingDirectoryRemoved, directory: primary }, ); @@ -109,7 +125,7 @@ suite('Session working directories', () => { () => resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectorySet, directory: 'not a URI' }, [primary], - true, + capImmutable, ), /Scheme is missing/, ); @@ -117,9 +133,96 @@ suite('Session working directories', () => { () => resolveSessionWorkingDirectoryAction( { type: ActionType.SessionWorkingDirectorySet, directory: 'vscode-remote://ssh-remote+host/workspace' }, [primary], - true, + capImmutable, ), /Working directory must be a file URI/, ); }); + + test('canonicalizes both directory and replacement in a replace action', () => { + assert.deepStrictEqual( + resolveSessionWorkingDirectoryAction( + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: 'file:///workspace/%73econdary', + replacement: 'file:///workspace/%72eplacement', + }, + [primary, secondary, replacement], + capImmutable, + ), + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: secondary, + replacement, + }, + ); + }); + + test('accepts a replace with no matching target as a canonicalized no-op payload', () => { + assert.deepStrictEqual( + resolveSessionWorkingDirectoryAction( + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: 'file:///workspace/absent', + replacement, + }, + [primary, secondary], + capImmutable, + ), + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: 'file:///workspace/absent', + replacement, + }, + ); + }); + + test('rejects replace when the primary is immutable without primaryReplacement', () => { + assert.throws( + () => resolveSessionWorkingDirectoryAction( + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: primary, + replacement, + }, + [primary, secondary], + capImmutable, + ), + /The primary working directory cannot be replaced/, + ); + }); + + test('allows primary replace when the provider advertises primaryReplacement', () => { + assert.deepStrictEqual( + resolveSessionWorkingDirectoryAction( + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: primary, + replacement, + }, + [primary, secondary], + capReplaceablePrimary, + ), + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: primary, + replacement, + }, + ); + }); + + test('rejects a replace with a non-file replacement URI', () => { + assert.throws( + () => resolveSessionWorkingDirectoryAction( + { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: secondary, + replacement: 'vscode-remote://ssh-remote+host/workspace', + }, + [primary, secondary], + capImmutable, + ), + /Working directory replacement must be a file URI/, + ); + }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index d77a2192403982..bd42ae5bfcc7c1 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -28,7 +28,7 @@ import { ActionType, type ChatTurnStartedAction, type SessionActiveClientSetActi import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { buildChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, buildChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; import { NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; @@ -2500,6 +2500,58 @@ suite('AgentHostProtocolClient', () => { client.dispose(); }); + test('marks an exact-channel subscription missing when restore fails', async function () { + this.timeout(10_000); + const { client, transports } = createFactoryClient(); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const catalogRef = client.getSubscriptionByChannel(StateComponents.AutomationCatalog, AUTOMATION_CATALOG_URI, 'test'); + const initialSubscribe = await waitForRequest(transports[0], 'subscribe'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialSubscribe.id, + result: { snapshot: { resource: AUTOMATION_CATALOG_URI, state: { automations: [] }, fromSeq: 5 } }, + }); + await flushMicrotasks(); + + transports[0].fireClose(); + await waitForReconnecting(client); + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.complete(); + const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: reconnect.id, + error: { code: AhpErrorCodes.NotFound, message: 'Reconnect client not found' }, + }); + const initialize = await waitForRequest(reconnectTransport, 'initialize'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { + protocolVersion: PROTOCOL_VERSION, + serverSeq: 0, + snapshots: [{ resource: ROOT_STATE_URI, state: { agents: [], activeSessions: 0 }, fromSeq: 0 }], + }, + }); + + const restoredSubscribe = await waitForRequest(reconnectTransport, 'subscribe'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: restoredSubscribe.id, + error: { code: JsonRpcErrorCodes.InternalError, message: 'Catalogue unavailable' }, + }); + await flushMicrotasks(); + + assert.deepStrictEqual({ + channel: (restoredSubscribe.params as { channel: string }).channel, + valueIsError: catalogRef.object.value instanceof Error, + }, { + channel: AUTOMATION_CATALOG_URI, + valueIsError: true, + }); + + catalogRef.dispose(); + client.dispose(); + }); + test('replays pending optimistic actions after reconnect', async function () { this.timeout(10_000); return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts new file mode 100644 index 00000000000000..b7bad282195667 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -0,0 +1,1055 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../common/automationMigration.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { AutomationMisfirePolicy, AutomationOperation, AutomationTriggerKind, type AutomationDefinition } from '../../common/state/protocol/channels-automation/state.js'; +import { AutomationRunOriginKind, AutomationRunStatus, type AutomationRunState } from '../../common/state/protocol/channels-automation-run/state.js'; +import { buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostAutomationService, type IAgentHostAutomationExecution } from '../../node/agentHostAutomationService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostStorageService, type IAgentHostStorageWriter } from '../../node/agentHostStorageService.js'; + +suite('AgentHostAutomationService', () => { + + let disposables: DisposableStore; + let stateManager: AgentHostStateManager; + let storageService: AgentHostStorageService; + let writeFailures: number; + let writeAttempts: number; + + setup(() => { + disposables = new DisposableStore(); + stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY]: true }, + }); + writeFailures = 0; + writeAttempts = 0; + const writer: IAgentHostStorageWriter = { + mkdir: async () => { }, + writeFile: async () => { + writeAttempts++; + if (writeFailures > 0) { + writeFailures--; + throw new Error('storage unavailable'); + } + }, + }; + storageService = disposables.add(new AgentHostStorageService( + URI.file(`/agent-host-automation-service-${generateUuid()}.json`), + new NullLogService(), + writer, + )); + }); + + teardown(() => disposables.dispose()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function definition(): AutomationDefinition { + return { + title: 'Review changes', + message: { text: 'Review the current changes.', origin: { kind: MessageKind.Automation } }, + session: { provider: 'mock' }, + enabled: true, + triggers: [], + }; + } + + function createAction(resource = 'ahp-automation:/review-changes') { + return { + type: ActionType.AutomationCreateRequested, + resource, + definition: definition(), + } as const; + } + + function createService(execution?: Partial): AgentHostAutomationService { + const service = new AgentHostAutomationService({ + isSessionTemplateAvailable: execution?.isSessionTemplateAvailable ?? (() => true), + createSession: execution?.createSession ?? (async () => { throw new Error('Unexpected session creation'); }), + startSession: execution?.startSession ?? (async () => { throw new Error('Unexpected session start'); }), + cancelSession: execution?.cancelSession ?? (async () => false), + }, stateManager, storageService, new NullLogService()); + return disposables.add(service); + } + + async function enableAndCreate(service: AgentHostAutomationService, resource = 'ahp-automation:/review-changes'): Promise { + await service.completeMigration(); + await service.handleCreate(createAction(resource)); + } + + test('execution remains gated after migration persistence failure and retries safely', async () => { + const service = createService(); + writeFailures = 1; + + await assert.rejects(service.completeMigration(), /storage unavailable/); + assert.deepStrictEqual(service.capabilities, { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: 50 }); + + await service.completeMigration(); + + assert.deepStrictEqual({ + writeAttempts, + capabilities: service.capabilities, + catalog: stateManager.getAutomationCatalogState(), + }, { + writeAttempts: 3, + capabilities: { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: 50 }, + catalog: { automations: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } }, + }); + }); + + test('a future host automation storage version disables the capability without rewriting data', async () => { + storageService.set('automations', { + version: 2, + catalog: { automations: [] }, + }); + await storageService.whenIdle(); + const service = createService(); + + await assert.rejects(service.completeMigration(), /storage is unavailable/); + assert.deepStrictEqual({ + isAvailable: service.isAvailable, + capabilities: service.capabilities, + storedVersion: storageService.get<{ version: number }>('automations')?.version, + }, { + isAvailable: false, + capabilities: undefined, + storedVersion: 2, + }); + }); + + test('failed catalogue persistence publishes nothing and a retry creates one entry', async () => { + const service = createService(); + await service.completeMigration(); + writeFailures = 1; + + await assert.rejects(service.handleCreate(createAction()), /storage unavailable/); + assert.deepStrictEqual(stateManager.getAutomationCatalogState(), { + automations: [], + _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true }, + }); + + await service.handleCreate(createAction()); + + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations.map(automation => ({ + resource: automation.resource, + operations: automation.operations, + })), [{ + resource: 'ahp-automation:/review-changes', + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + }]); + }); + + test('partial migration cannot unblock execution', async () => { + const service = createService(); + await service.handleCreate(createAction()); + + await assert.rejects( + service.completeMigration(['ahp-automation:/review-changes', 'ahp-automation:/missing']), + /1 expected automation resources are missing/, + ); + await assert.rejects(service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'blocked-request', + }), /migration must complete/); + + assert.deepStrictEqual({ + capabilities: service.capabilities, + operations: stateManager.getAutomationCatalogState()?.automations[0].operations, + }, { + capabilities: { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: 50 }, + operations: [AutomationOperation.Update, AutomationOperation.Remove], + }); + + await service.completeMigration(['ahp-automation:/review-changes']); + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + AutomationOperation.Remove, + AutomationOperation.Run, + ]); + }); + + test('feature disablement removes run permission and blocks execution in the host', async () => { + const service = createService(); + await enableAndCreate(service); + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY]: false }, + }); + await service.handleConfigurationChanged(); + + await assert.rejects(service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'disabled-request', + }), /Automations are disabled/); + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + AutomationOperation.Remove, + ]); + + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY]: true }, + }); + await service.handleConfigurationChanged(); + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + AutomationOperation.Remove, + AutomationOperation.Run, + ]); + }); + + test('manual run is durable, idempotent, linked before send, and completed from chat state', async () => { + const session = URI.parse('mock:/automation-session'); + const started = new DeferredPromise<{ readonly turnId: string }>(); + let createCalls = 0; + let startedMessageKind: MessageKind | undefined; + const service = createService({ + createSession: async () => { + createCalls++; + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async (createdSession, message) => { + const turnId = 'automation-turn'; + startedMessageKind = message.origin.kind; + stateManager.dispatchServerAction(buildDefaultChatUri(createdSession), { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date().toISOString(), + message, + }); + await started.complete({ turnId }); + }, + }); + await enableAndCreate(service); + + const params = { + channel: 'ahp-automations://catalog' as const, + automation: 'ahp-automation:/review-changes', + requestId: 'manual-request', + }; + const first = await service.runAutomation(params); + const second = await service.runAutomation(params); + const concurrent = await service.runAutomation({ ...params, requestId: 'concurrent-request' }); + const { turnId } = await started.p; + + const running = stateManager.getAutomationRunState(first.resource); + assert.deepStrictEqual({ + first, + second, + concurrent, + createCalls, + status: running?.lifecycle.status, + sessions: running?.sessions, + primarySession: running?.primarySession, + catalogRuns: stateManager.getAutomationCatalogState()?.automations[0].runs.length, + startedMessageKind, + }, { + first: second, + second, + concurrent: second, + createCalls: 1, + status: AutomationRunStatus.Running, + sessions: [session.toString()], + primarySession: session.toString(), + catalogRuns: 1, + startedMessageKind: MessageKind.Automation, + }); + + const completed = new DeferredPromise(); + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === first.resource + && envelope.action.type === ActionType.AutomationRunLifecycleChanged + && envelope.action.lifecycle.status === AutomationRunStatus.Completed) { + void completed.complete(); + } + })); + stateManager.dispatchServerAction(buildDefaultChatUri(session), { + type: ActionType.ChatTurnComplete, + turnId, + duration: 10, + }); + await completed.p; + + assert.deepStrictEqual({ + run: stateManager.getAutomationRunState(first.resource)?.lifecycle.status, + summary: stateManager.getAutomationCatalogState()?.automations[0].runs[0].lifecycle.status, + }, { + run: AutomationRunStatus.Completed, + summary: AutomationRunStatus.Completed, + }); + }); + + test('run persistence failure prevents session side effects', async () => { + let createCalls = 0; + const service = createService({ + createSession: async () => { + createCalls++; + return URI.parse('mock:/unexpected'); + }, + }); + await enableAndCreate(service); + writeFailures = 1; + + await assert.rejects(service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'failed-request', + }), /storage unavailable/); + + assert.deepStrictEqual({ + createCalls, + runs: stateManager.getAutomationCatalogState()?.automations[0].runs, + }, { + createCalls: 0, + runs: [], + }); + }); + + test('pending execution waits for provider registration', async () => { + let available = false; + let createCalls = 0; + const started = new DeferredPromise(); + const session = URI.parse('mock:/deferred-session'); + const service = createService({ + isSessionTemplateAvailable: () => available, + createSession: async () => { + createCalls++; + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async () => { + await started.complete(); + }, + }); + await enableAndCreate(service); + + const result = await service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'deferred-request', + }); + await Promise.resolve(); + assert.deepStrictEqual({ + createCalls, + status: stateManager.getAutomationRunState(result.resource)?.lifecycle.status, + }, { + createCalls: 0, + status: AutomationRunStatus.Pending, + }); + + available = true; + service.handleAgentsChanged(); + await started.p; + assert.deepStrictEqual({ + createCalls, + status: stateManager.getAutomationRunState(result.resource)?.lifecycle.status, + }, { + createCalls: 1, + status: AutomationRunStatus.Running, + }); + }); + + test('host timeout terminates a hung run so later occurrences cannot overlap', () => runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 100 }, async () => { + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY]: 1 }, + }); + const session = URI.parse('mock:/hung-session'); + const started = new DeferredPromise(); + + const service = createService({ + createSession: async () => { + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async () => { + await started.complete(); + }, + cancelSession: async () => false, + }); + await enableAndCreate(service); + const failed = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, envelope => + envelope.action.type === ActionType.AutomationRunLifecycleChanged + && envelope.action.lifecycle.status === AutomationRunStatus.Failed + )); + + const result = await service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'hung-request', + }); + await started.p; + await failed; + + const run = stateManager.getAutomationRunState(result.resource); + assert.deepStrictEqual({ + status: run?.lifecycle.status, + error: run?.lifecycle.status === AutomationRunStatus.Failed ? run.lifecycle.error.message : undefined, + removeAvailable: stateManager.getAutomationCatalogState()?.automations[0].operations.includes(AutomationOperation.Remove), + }, { + status: AutomationRunStatus.Failed, + error: 'Automation run timed out.', + removeAvailable: true, + }); + })); + + test('cancellation wins a session-creation race without sending the prompt', async () => { + const session = URI.parse('mock:/cancelled-session'); + const createStarted = new DeferredPromise(); + const releaseCreate = new DeferredPromise(); + const cancelled = new DeferredPromise(); + let startCalls = 0; + const service = createService({ + createSession: async () => { + await createStarted.complete(); + await releaseCreate.p; + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async () => { + startCalls++; + }, + cancelSession: async () => { + await cancelled.complete(); + return true; + }, + }); + await enableAndCreate(service); + const result = await service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'cancel-request', + }); + await createStarted.p; + + await service.handleCancel(result.resource, { type: ActionType.AutomationRunCancelRequested }); + await releaseCreate.complete(); + await cancelled.p; + + const run = stateManager.getAutomationRunState(result.resource); + assert.deepStrictEqual({ + startCalls, + status: run?.lifecycle.status, + hasStartedAt: run?.lifecycle.status === AutomationRunStatus.Cancelled && run.lifecycle.startedAt !== undefined, + hasCompletedAt: run?.lifecycle.status === AutomationRunStatus.Cancelled && run.lifecycle.completedAt.length > 0, + sessions: run?.sessions, + primarySession: run?.primarySession, + }, { + startCalls: 0, + status: AutomationRunStatus.Cancelled, + hasStartedAt: true, + hasCompletedAt: true, + sessions: [session.toString()], + primarySession: session.toString(), + }); + }); + + test('failed linked-session cancellation leaves the run non-terminal', async () => { + const session = URI.parse('mock:/uncancelled-session'); + const started = new DeferredPromise(); + + const service = createService({ + createSession: async () => { + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async () => { + await started.complete(); + }, + cancelSession: async () => { + throw new Error('cancel failed'); + }, + }); + await enableAndCreate(service); + const result = await service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/review-changes', + requestId: 'cancel-failure', + }); + await started.p; + + await assert.rejects(service.handleCancel(result.resource, { type: ActionType.AutomationRunCancelRequested }), /cancel failed/); + + assert.strictEqual(stateManager.getAutomationRunState(result.resource)?.lifecycle.status, AutomationRunStatus.Running); + }); + + test('claims a persisted missed schedule before starting its session', async () => { + const now = new Date(); + const scheduledFor = new Date(now.getTime() - 2 * 60_000).toISOString(); + const automationResource = 'ahp-automation:/scheduled-review'; + const scheduledDefinition: AutomationDefinition = { + ...definition(), + triggers: [{ + id: 'weekday-review', + kind: AutomationTriggerKind.Schedule, + schedule: { expression: '* * * * *', timeZone: 'UTC' }, + misfirePolicy: AutomationMisfirePolicy.RunOnce, + }], + }; + storageService.set('automations', { + catalog: { + automations: [{ + resource: automationResource, + definition: scheduledDefinition, + nextRunAt: scheduledFor, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: now.toISOString(), + modifiedAt: now.toISOString(), + _meta: { 'vscode.scheduleCursors': { 'weekday-review': scheduledFor } }, + }], + }, + runs: [], + manualRunRequests: [], + migration: { status: 'complete', completedAt: now.toISOString() }, + }); + await storageService.whenIdle(); + + const session = URI.parse('mock:/scheduled-session'); + const started = new DeferredPromise(); + + const service = createService({ + createSession: async () => { + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async (createdSession, message) => { + stateManager.dispatchServerAction(buildDefaultChatUri(createdSession), { + type: ActionType.ChatTurnStarted, + turnId: 'scheduled-turn', + startedAt: new Date().toISOString(), + message, + }); + await started.complete(); + }, + }); + await started.p; + + const automation = stateManager.getAutomationCatalogState()?.automations[0]; + const run = automation?.runs[0]; + assert.deepStrictEqual({ + origin: run?.origin, + status: run?.lifecycle.status, + primarySession: run?.primarySession, + nextRunIsFuture: Date.parse(automation?.nextRunAt ?? '') > now.getTime(), + serviceAvailable: service.isAvailable, + }, { + origin: { + kind: AutomationRunOriginKind.Trigger, + triggerId: 'weekday-review', + scheduledFor, + catchUp: true, + }, + status: AutomationRunStatus.Running, + primarySession: session.toString(), + nextRunIsFuture: true, + serviceAvailable: true, + }); + }); + + test('coalesces simultaneously-due schedule triggers on one Automation into a single run', async () => { + const now = new Date(); + const firstScheduledFor = new Date(now.getTime() - 3 * 60_000).toISOString(); + const secondScheduledFor = new Date(now.getTime() - 2 * 60_000).toISOString(); + const automationResource = 'ahp-automation:/multi-trigger'; + const multiTriggerDefinition: AutomationDefinition = { + ...definition(), + triggers: [ + { + id: 'first-trigger', + kind: AutomationTriggerKind.Schedule, + schedule: { expression: '* * * * *', timeZone: 'UTC' }, + misfirePolicy: AutomationMisfirePolicy.RunOnce, + }, + { + id: 'second-trigger', + kind: AutomationTriggerKind.Schedule, + schedule: { expression: '*/2 * * * *', timeZone: 'UTC' }, + misfirePolicy: AutomationMisfirePolicy.RunOnce, + }, + ], + }; + storageService.set('automations', { + catalog: { + automations: [{ + resource: automationResource, + definition: multiTriggerDefinition, + nextRunAt: firstScheduledFor, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: now.toISOString(), + modifiedAt: now.toISOString(), + _meta: { + 'vscode.scheduleCursors': { + 'first-trigger': firstScheduledFor, + 'second-trigger': secondScheduledFor, + }, + }, + }], + }, + runs: [], + manualRunRequests: [], + migration: { status: 'complete', completedAt: now.toISOString() }, + }); + await storageService.whenIdle(); + + const session = URI.parse('mock:/multi-trigger-session'); + const started = new DeferredPromise(); + createService({ + createSession: async () => { + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async () => { + await started.complete(); + }, + }); + await started.p; + + const automation = stateManager.getAutomationCatalogState()?.automations[0]; + const cursors = automation?._meta?.['vscode.scheduleCursors'] as Record | undefined; + assert.deepStrictEqual({ + runsClaimed: automation?.runs.length, + claimedTriggerId: automation?.runs[0]?.origin.kind === AutomationRunOriginKind.Trigger ? automation.runs[0].origin.triggerId : undefined, + firstCursorAdvanced: cursors ? Date.parse(cursors['first-trigger']) > now.getTime() : false, + secondCursorAdvanced: cursors ? Date.parse(cursors['second-trigger']) > now.getTime() : false, + }, { + runsClaimed: 1, + claimedTriggerId: 'first-trigger', + firstCursorAdvanced: true, + secondCursorAdvanced: true, + }); + }); + + test('Skip-catch-up on the first trigger does not consume the per-tick claim slot', async () => { + const now = new Date(); + const stale = new Date(now.getTime() - 10 * 60_000).toISOString(); + const dueRecently = new Date(now.getTime() - 30_000).toISOString(); + const automationResource = 'ahp-automation:/skip-first'; + const multiTriggerDefinition: AutomationDefinition = { + ...definition(), + triggers: [ + { + id: 'stale-skip-trigger', + kind: AutomationTriggerKind.Schedule, + schedule: { expression: '* * * * *', timeZone: 'UTC' }, + misfirePolicy: AutomationMisfirePolicy.Skip, + }, + { + id: 'due-run-trigger', + kind: AutomationTriggerKind.Schedule, + schedule: { expression: '*/2 * * * *', timeZone: 'UTC' }, + misfirePolicy: AutomationMisfirePolicy.RunOnce, + }, + ], + }; + storageService.set('automations', { + catalog: { + automations: [{ + resource: automationResource, + definition: multiTriggerDefinition, + nextRunAt: stale, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: now.toISOString(), + modifiedAt: now.toISOString(), + _meta: { + 'vscode.scheduleCursors': { + 'stale-skip-trigger': stale, + 'due-run-trigger': dueRecently, + }, + }, + }], + }, + runs: [], + manualRunRequests: [], + migration: { status: 'complete', completedAt: now.toISOString() }, + }); + await storageService.whenIdle(); + + const session = URI.parse('mock:/skip-first-session'); + const started = new DeferredPromise(); + + createService({ + createSession: async () => { + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async () => { + await started.complete(); + }, + }); + await started.p; + + const automation = stateManager.getAutomationCatalogState()?.automations[0]; + assert.deepStrictEqual({ + runsClaimed: automation?.runs.length, + claimedTriggerId: automation?.runs[0]?.origin.kind === AutomationRunOriginKind.Trigger ? automation.runs[0].origin.triggerId : undefined, + }, { + runsClaimed: 1, + claimedTriggerId: 'due-run-trigger', + }); + }); + + test('bounds catalogue run history and loads older pages by cursor', async () => { + const automationResource = 'ahp-automation:/history'; + const runs: AutomationRunState[] = Array.from({ length: 51 }, (_, index) => { + const timestamp = new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(); + return { + resource: `ahp-automation-run:/run-${index}`, + automation: automationResource, + origin: { kind: AutomationRunOriginKind.Manual }, + lifecycle: { + status: AutomationRunStatus.Completed, + createdAt: timestamp, + startedAt: timestamp, + completedAt: timestamp, + }, + sessions: [], + }; + }); + storageService.set('automations', { + catalog: { + automations: [{ + resource: automationResource, + definition: definition(), + runs: runs.map(run => ({ + resource: run.resource, + automation: run.automation, + origin: run.origin, + lifecycle: run.lifecycle, + sessionCount: 0, + })), + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + }], + }, + runs, + manualRunRequests: [], + migration: { status: 'complete', completedAt: '2026-01-01T00:00:00.000Z' }, + }); + await storageService.whenIdle(); + const service = createService(); + + assert.deepStrictEqual({ + count: stateManager.getAutomationCatalogState()?.automations[0].runs.length, + cursor: stateManager.getAutomationCatalogState()?.automations[0].runsNextCursor, + }, { + count: 50, + cursor: '50', + }); + + await service.fetchAutomationRuns({ + channel: 'ahp-automations://catalog', + automation: automationResource, + cursor: '50', + }); + + assert.deepStrictEqual({ + count: stateManager.getAutomationCatalogState()?.automations[0].runs.length, + cursor: stateManager.getAutomationCatalogState()?.automations[0].runsNextCursor, + }, { + count: 51, + cursor: undefined, + }); + }); + + test('a create staged as an import-pending row is never granted Run authority', async () => { + const service = createService(); + await service.completeMigration(); + await service.handleCreate({ + type: ActionType.AutomationCreateRequested, + resource: 'ahp-automation:/pending-import', + definition: { + ...definition(), + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }, + }); + + await assert.rejects(service.runAutomation({ + channel: 'ahp-automations://catalog', + automation: 'ahp-automation:/pending-import', + requestId: 'pending-request', + }), /not available/i); + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + ]); + }); + + test('clearing the import-pending flag restores Run and Remove authority', async () => { + const service = createService(); + await service.completeMigration(); + await service.handleCreate({ + type: ActionType.AutomationCreateRequested, + resource: 'ahp-automation:/pending-import', + definition: { + ...definition(), + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }, + }); + await service.handleUpdate({ + type: ActionType.AutomationUpdateRequested, + resource: 'ahp-automation:/pending-import', + changes: { _meta: {} }, + }); + + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + AutomationOperation.Remove, + AutomationOperation.Run, + ]); + }); + + test('staging an existing Automation as import-pending removes Run and Remove authority', async () => { + const service = createService(); + await service.completeMigration(); + await service.handleCreate({ + type: ActionType.AutomationCreateRequested, + resource: 'ahp-automation:/existing', + definition: definition(), + }); + await service.handleUpdate({ + type: ActionType.AutomationUpdateRequested, + resource: 'ahp-automation:/existing', + changes: { _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true } }, + }); + + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + ]); + }); + + test('completeMigration withholds Run and Remove from pending imports', async () => { + const service = createService(); + await service.handleCreate({ + type: ActionType.AutomationCreateRequested, + resource: 'ahp-automation:/pending-import', + definition: { + ...definition(), + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }, + }); + await service.handleCreate({ + type: ActionType.AutomationCreateRequested, + resource: 'ahp-automation:/clean-import', + definition: definition(), + }); + + await service.completeMigration(); + + const automations = stateManager.getAutomationCatalogState()?.automations ?? []; + const byResource = new Map(automations.map(automation => [automation.resource, automation.operations])); + assert.deepStrictEqual({ + pending: byResource.get('ahp-automation:/pending-import'), + clean: byResource.get('ahp-automation:/clean-import'), + }, { + pending: [AutomationOperation.Update], + clean: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + }); + }); + + test('re-enabling automations still withholds Run from a pending import', async () => { + const service = createService(); + await service.completeMigration(); + await service.handleCreate({ + type: ActionType.AutomationCreateRequested, + resource: 'ahp-automation:/pending-import', + definition: { + ...definition(), + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }, + }); + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY]: false }, + }); + await service.handleConfigurationChanged(); + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY]: true }, + }); + await service.handleConfigurationChanged(); + + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + AutomationOperation.Update, + ]); + }); + + test('the scheduler skips a persisted pending row on restart', async () => { + const now = new Date(); + const scheduledFor = new Date(now.getTime() - 2 * 60_000).toISOString(); + const scheduledDefinition: AutomationDefinition = { + ...definition(), + triggers: [{ + id: 'weekday-review', + kind: AutomationTriggerKind.Schedule, + schedule: { expression: '* * * * *', timeZone: 'UTC' }, + misfirePolicy: AutomationMisfirePolicy.RunOnce, + }], + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }; + storageService.set('automations', { + catalog: { + automations: [{ + resource: 'ahp-automation:/pending-scheduled', + definition: scheduledDefinition, + nextRunAt: scheduledFor, + runs: [], + // Post-fix persisted state: no Run because the row is + // still import-pending. The scheduler must respect this. + operations: [AutomationOperation.Update], + createdAt: now.toISOString(), + modifiedAt: now.toISOString(), + _meta: { + 'vscode.scheduleCursors': { 'weekday-review': scheduledFor }, + [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true, + }, + }], + }, + runs: [], + manualRunRequests: [], + migration: { status: 'complete', completedAt: now.toISOString() }, + }); + await storageService.whenIdle(); + + let createCalls = 0; + const service = createService({ + createSession: async () => { + createCalls++; + return URI.parse('mock:/should-not-start'); + }, + }); + await service.completeMigration(); + + const automation = stateManager.getAutomationCatalogState()?.automations[0]; + assert.deepStrictEqual({ + createCalls, + operations: automation?.operations, + runCount: automation?.runs.length, + }, { + createCalls: 0, + operations: [AutomationOperation.Update], + runCount: 0, + }); + }); + + test('run recovery on restart skips a pending row even if a run was persisted', async () => { + const now = new Date(); + const scheduledDefinition: AutomationDefinition = { + ...definition(), + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }; + const pendingRun: AutomationRunState = { + resource: 'ahp-automation-run:/pending-run', + automation: 'ahp-automation:/pending-import', + origin: { kind: AutomationRunOriginKind.Manual }, + lifecycle: { status: AutomationRunStatus.Pending, createdAt: now.toISOString() }, + sessions: [], + }; + storageService.set('automations', { + catalog: { + automations: [{ + resource: 'ahp-automation:/pending-import', + definition: scheduledDefinition, + runs: [pendingRun], + // Post-fix persisted state should not include Run because + // the item is still pending. The recovery gate must respect + // that even though a Pending run is on disk. + operations: [AutomationOperation.Update], + createdAt: now.toISOString(), + modifiedAt: now.toISOString(), + _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true }, + }], + }, + runs: [pendingRun], + manualRunRequests: [], + migration: { status: 'complete', completedAt: now.toISOString() }, + }); + await storageService.whenIdle(); + + let createCalls = 0; + const service = createService({ + createSession: async () => { + createCalls++; + return URI.parse('mock:/should-not-start'); + }, + }); + await service.completeMigration(); + + assert.strictEqual(createCalls, 0); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts b/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts new file mode 100644 index 00000000000000..8662c617868c2f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { flushAgentHostPersistenceBeforeShutdown } from '../../node/agentHostShutdown.js'; + +suite('AgentHostShutdown', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a failed persistence flush does not reject shutdown', async () => { + await assert.doesNotReject(() => flushAgentHostPersistenceBeforeShutdown( + [Promise.reject(new Error('storage unavailable'))], + 3000, + new NullLogService(), + )); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts b/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts index 312ff114c4bcd6..072b0ac75cab5b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts @@ -4,9 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { readFile, unlink, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { getRandomTestPath } from '../../../../base/test/node/testUtils.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStorageService, type IAgentHostStorageWriter } from '../../node/agentHostStorageService.js'; @@ -47,4 +50,91 @@ suite('AgentHostStorageService', () => { lastWrite: { first: { value: 1 } }, }); }); + + test('surfaces a write failure until a later write succeeds', async () => { + let attempts = 0; + const writer: IAgentHostStorageWriter = { + mkdir: async () => { }, + writeFile: async () => { + attempts++; + if (attempts === 1) { + throw new Error('disk is unavailable'); + } + }, + }; + const service = disposables.add(new AgentHostStorageService( + URI.file('/agent-host-storage-service-test.json'), + new NullLogService(), + writer, + )); + + service.set('first', true); + await assert.rejects(service.whenIdle(), /disk is unavailable/); + await assert.rejects(service.whenIdle(), /disk is unavailable/); + + service.set('second', true); + await service.whenIdle(); + + assert.deepStrictEqual({ + attempts, + first: service.get('first'), + second: service.get('second'), + }, { + attempts: 2, + first: true, + second: true, + }); + }); + + test('a corrupt storage file is never overwritten', async () => { + const path = getRandomTestPath(tmpdir()) + '.json'; + await writeFile(path, 'not json', 'utf8'); + try { + const service = disposables.add(new AgentHostStorageService(URI.file(path), new NullLogService())); + + assert.throws(() => service.set('automations', { catalog: { automations: [] } }), /persisted data could not be loaded/); + await assert.rejects(service.whenIdle(), /persisted data could not be loaded/); + assert.deepStrictEqual({ + hasLoadError: service.loadError instanceof Error, + persisted: await readFile(path, 'utf8'), + }, { + hasLoadError: true, + persisted: 'not json', + }); + } finally { + await unlink(path); + } + }); + + test('a rejected flushed value cannot be resurrected by a later unrelated write', async () => { + let fail = true; + const writes: string[] = []; + const writer: IAgentHostStorageWriter = { + mkdir: async () => { }, + writeFile: async (_path, contents) => { + if (fail) { + fail = false; + throw new Error('disk unavailable'); + } + writes.push(contents); + }, + }; + const service = disposables.add(new AgentHostStorageService( + URI.file('/agent-host-storage-service-test.json'), + new NullLogService(), + writer, + )); + + await assert.rejects(service.setAndFlush('automations', { value: 'rejected' }), /disk unavailable/); + service.set('unrelated', true); + await service.whenIdle(); + + assert.deepStrictEqual({ + automationValue: service.get('automations'), + persisted: JSON.parse(writes.at(-1)!), + }, { + automationValue: undefined, + persisted: { unrelated: true }, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 13cfac1d744924..f897e7bec0df69 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4574,6 +4574,32 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(registered, new Set([legacy.toString()])); }); + test('waits for initial provider migration before refreshing Automations', async () => { + const migrationStarted = new DeferredPromise(); + const migrationGate = new DeferredPromise(); + class GatedMigrationAgent extends MockAgent { + override async listChatsToMigrate(): Promise { + await migrationStarted.complete(); + await migrationGate.p; + return []; + } + } + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + let automationRefreshes = 0; + (svc as unknown as { _automationService: { handleAgentsChanged(): void } })._automationService.handleAgentsChanged = () => automationRefreshes++; + const agent = disposables.add(new GatedMigrationAgent('copilot')); + + registerTestAgentProvider(svc, agent); + await migrationStarted.p; + assert.strictEqual(automationRefreshes, 0); + + await migrationGate.complete(); + for (let i = 0; i < 20 && automationRefreshes === 0; i++) { + await timeout(0); + } + assert.strictEqual(automationRefreshes, 1); + }); + test('a provider whose native catalog gains a chat is discovered on its chat-list-changed signal', async () => { class LateEnumerableAgent extends MockAgent { private readonly _onDidDiscoverChats = new Emitter(); diff --git a/src/vs/platform/agentHost/test/node/automationCron.test.ts b/src/vs/platform/agentHost/test/node/automationCron.test.ts new file mode 100644 index 00000000000000..044a1be7ca0abb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/automationCron.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { nextAutomationCronOccurrence, validateAutomationCron } from '../../node/automationCron.js'; + +suite('Automation cron', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('supports steps, names, ranges, and Sunday 7', () => { + assert.deepStrictEqual({ + step: nextAutomationCronOccurrence('*/15 * * * *', 'UTC', new Date('2026-01-01T00:07:00Z')).toISOString(), + names: nextAutomationCronOccurrence('30 9 * JAN MON-FRI', 'UTC', new Date('2026-01-02T09:30:00Z')).toISOString(), + sunday: nextAutomationCronOccurrence('0 12 * * 7', 'UTC', new Date('2026-01-03T12:00:00Z')).toISOString(), + }, { + step: '2026-01-01T00:15:00.000Z', + names: '2026-01-05T09:30:00.000Z', + sunday: '2026-01-04T12:00:00.000Z', + }); + }); + + test('uses Unix OR semantics for restricted day fields', () => { + assert.strictEqual( + nextAutomationCronOccurrence('0 0 15 * MON', 'UTC', new Date('2026-01-12T00:00:00Z')).toISOString(), + '2026-01-15T00:00:00.000Z', + ); + }); + + test('evaluates wall-clock fields in the requested time zone', () => { + assert.strictEqual( + nextAutomationCronOccurrence('0 9 * * *', 'America/Los_Angeles', new Date('2026-06-01T15:59:00Z')).toISOString(), + '2026-06-01T16:00:00.000Z', + ); + }); + + test('finds sparse annual and leap-day schedules', () => { + assert.deepStrictEqual({ + annual: nextAutomationCronOccurrence('0 0 1 JAN *', 'UTC', new Date('2026-01-02T00:00:00Z')).toISOString(), + leapDay: nextAutomationCronOccurrence('0 0 29 FEB *', 'UTC', new Date('2024-03-01T00:00:00Z')).toISOString(), + }, { + annual: '2027-01-01T00:00:00.000Z', + leapDay: '2028-02-29T00:00:00.000Z', + }); + }); + + test('handles missing and repeated wall-clock times at DST transitions', () => { + assert.deepStrictEqual({ + missing: nextAutomationCronOccurrence('30 2 * * *', 'America/Los_Angeles', new Date('2026-03-08T09:59:00Z')).toISOString(), + repeated: nextAutomationCronOccurrence('30 1 * * *', 'America/Los_Angeles', new Date('2026-11-01T08:31:00Z')).toISOString(), + restrictedMissing: nextAutomationCronOccurrence('30 2 8 MAR *', 'America/Los_Angeles', new Date('2026-03-01T00:00:00Z')).toISOString(), + }, { + missing: '2026-03-09T09:30:00.000Z', + repeated: '2026-11-01T09:30:00.000Z', + restrictedMissing: '2027-03-08T10:30:00.000Z', + }); + }); + + test('rejects unsupported grammar and invalid time zones', () => { + assert.throws(() => validateAutomationCron('@daily', 'UTC'), /exactly five fields/); + assert.throws(() => validateAutomationCron('0 0 ? * *', 'UTC'), /outside 1-31/); + assert.throws(() => validateAutomationCron('0 0 30 2 *', 'UTC'), /cannot match a real calendar date/); + assert.throws(() => validateAutomationCron('0 0 * * *', 'Not\/AZone'), /invalid time zone/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index c3c8a8a78f5524..968ca55af8d090 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -18,11 +18,12 @@ import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/tel import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; -import type { Implementation } from '../../common/state/protocol/common/commands.js'; -import { ActionType, type ActionEnvelope, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ProgressParams } from '../../common/state/sessionActions.js'; +import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; +import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type ProgressParams, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; +import { AUTOMATION_CATALOG_URI, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams, SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -140,7 +141,7 @@ class TestTelemetryService implements ITelemetryService { class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; - readonly handledActions: (SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction)[] = []; + readonly handledActions: (SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction)[] = []; readonly handledClientTypes: (AgentHostClientType | undefined)[] = []; readonly handledClientContexts: (IAgentHostClientTelemetryContext | undefined)[] = []; readonly browsedUris: URI[] = []; @@ -158,6 +159,8 @@ class MockAgentService implements IAgentService { readonly subscribeCalls: { resource: string; clientId: string }[] = []; readonly unsubscribeCalls: { resource: string; clientId: string }[] = []; afterListSessionsSnapshot: (() => void) | undefined; + readonly automationRunRequests: RunAutomationParams[] = []; + automationRunResult: RunAutomationResult | undefined; private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; @@ -173,7 +176,7 @@ class MockAgentService implements IAgentService { this._stateManager = sm; } - dispatchAction(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void { + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void { this.handledActions.push(action); this.handledClientTypes.push(clientContext?.clientType); this.handledClientContexts.push(clientContext); @@ -200,6 +203,16 @@ class MockAgentService implements IAgentService { async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } async completions(_params: CompletionsParams): Promise { return { items: [] }; } + automationCapabilities: AutomationCapabilities | undefined; + async listAutomationTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { return { items: [] }; } + async runAutomation(params: RunAutomationParams): Promise { + this.automationRunRequests.push(params); + if (!this.automationRunResult) { + throw new Error('Not implemented'); + } + return this.automationRunResult; + } + async fetchAutomationRuns(_params: FetchAutomationRunsParams): Promise { return {}; } async getCompletionTriggerCharacters(): Promise { return []; } async disposeSession(_session: URI): Promise { } readonly createdChats: { session: string; chat: string; options?: IAgentCreateChatRequestOptions }[] = []; @@ -418,6 +431,19 @@ suite('ProtocolServerHandler', () => { }); }); + test('handshake advertises only implemented automation capabilities', () => { + agentService.automationCapabilities = { create: {}, runCancellation: {} }; + const transport = connectClient('automation-client'); + const resp = findResponse(transport.sent, 1); + if (!resp || !hasKey(resp, { result: true })) { + assert.fail('should have sent initialize response'); + } + assert.deepStrictEqual((resp.result as InitializeResult).automations, { + create: {}, + runCancellation: {}, + }); + }); + test('applies telemetry disablement before reporting the client connection', () => { const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket); server.simulateConnection(transport); @@ -581,6 +607,58 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString()); }); + test('automation catalogue subscription and run command preserve canonical channels', async () => { + stateManager.setAutomationCatalogState({ automations: [] }); + agentService.automationCapabilities = { create: {}, schedules: {}, runCancellation: {} }; + agentService.automationRunResult = { resource: 'ahp-automation-run:/run-1' }; + const transport = connectClient('automation-client'); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: AUTOMATION_CATALOG_URI })); + const subscription = await responsePromise; + const runResponsePromise = waitForResponse(transport, 3); + + transport.simulateMessage(request(3, 'runAutomation', { + channel: AUTOMATION_CATALOG_URI, + automation: 'ahp-automation:/automation-1', + requestId: 'request-1', + })); + const response = await runResponsePromise; + const subscribeResult = (subscription as { result?: SubscribeResult }).result; + + assert.deepStrictEqual({ + snapshot: subscribeResult?.snapshot, + requests: agentService.automationRunRequests, + response: hasKey(response, { result: true }) ? response.result : undefined, + }, { + snapshot: { + resource: AUTOMATION_CATALOG_URI, + state: { automations: [] }, + fromSeq: stateManager.serverSeq, + }, + requests: [{ + channel: AUTOMATION_CATALOG_URI, + automation: 'ahp-automation:/automation-1', + requestId: 'request-1', + }], + response: { resource: 'ahp-automation-run:/run-1' }, + }); + }); + + test('automation catalogue subscription rejects an inactive client before adding it', async () => { + stateManager.setAutomationCatalogState({ automations: [] }); + let subscriberAdded = false; + agentService.addSubscriber = () => subscriberAdded = true; + const target = handler as unknown as { + _subscribeStateChannel(channel: string, clientId: string, isActive?: () => boolean): Promise; + }; + + await assert.rejects( + target._subscribeStateChannel(AUTOMATION_CATALOG_URI, 'inactive-client', () => false), + /Subscription cancelled/, + ); + assert.strictEqual(subscriberAdded, false); + }); + test('handshake retains an initial subscription whose state has not materialized', () => { const transport = connectClient('client-1', [defaultChatUri]); const response = findResponse(transport.sent, 1) as { result: InitializeResult }; diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 9271ce7dd705ca..dbb4eecd142848 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -260,7 +260,7 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ }); }); - test('ChatInputRequested replacement preserves purpose and synchronized answers through completion', () => { + test('ChatInputRequested replacement preserves synchronized answers through completion', () => { let state = withActiveTurnAndToolCall(makeChat()); state = chatReducer(state, { type: ActionType.ChatInputRequested, diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index 02dee1ebeedff5..883e428c911d92 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -111,6 +111,37 @@ export class AutomationRunner implements IAutomationRunner { // gets the winner's run back instead of dispatching a duplicate session. const claim = await this.automationService.recordRunStart(automation.id, trigger, leaderWindowId); if (!claim.claimed) { + if (claim.externalDispatch) { + let cancellationForwarded = false; + const forwardCancellation = () => { + if (!cancellationForwarded) { + cancellationForwarded = true; + try { + claim.externalDispatch?.cancel?.(); + } catch (error) { + this.logService.error(`[AutomationRunner] Failed to forward cancellation for ${automation.id}`, error); + } + } + }; + const cancellationListener = claim.externalDispatch.cancel + ? token.onCancellationRequested(forwardCancellation) + : undefined; + const sessionResource = claim.externalDispatch.sessionResource; + try { + if (sessionResource) { + await dispatched.complete({ kind: 'started', run: claim.run, sessionResource }); + } else { + await dispatched.complete({ kind: 'notStarted', reason: 'error', run: claim.run }); + } + if (token.isCancellationRequested) { + forwardCancellation(); + } + await claim.externalDispatch.whenCompleted; + } finally { + cancellationListener?.dispose(); + } + return; + } this.logService.trace(`[AutomationRunner] skipping ${automation.id}: active run already exists.`); await dispatched.complete({ kind: 'alreadyRunning', activeRun: claim.run }); return; diff --git a/src/vs/sessions/contrib/automations/browser/automationScheduler.ts b/src/vs/sessions/contrib/automations/browser/automationScheduler.ts index 5c48827da5cf97..1dd654a3ebd31d 100644 --- a/src/vs/sessions/contrib/automations/browser/automationScheduler.ts +++ b/src/vs/sessions/contrib/automations/browser/automationScheduler.ts @@ -137,7 +137,10 @@ export class AutomationSchedulerCore extends Disposable { private async dispatchDue(trigger: 'schedule' | 'catch_up'): Promise { const now = this._now(); - const due = this.automationService.automations.get().filter(a => isDue(a, now)); + const due = this.automationService.automations.get().filter(automation => + this.automationService.isSchedulingOwnedByHost?.(automation.id) !== true + && isDue(automation, now) + ); if (due.length === 0) { return; } diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index 339cce8281ac3e..e0242910477256 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -109,6 +109,7 @@ const EMPTY_LEDGER: ILedger = Object.freeze({ automations: [], runs: [] }); type ReadLedgerResult = | { kind: 'ledger'; ledger: ILedger; revision: number } + | { kind: 'invalid'; ledger: ILedger; revision: number } | { kind: 'unsupportedSchema' }; export class AutomationStore extends Disposable implements IAutomationStore { @@ -119,6 +120,7 @@ export class AutomationStore extends Disposable implements IAutomationStore { private readonly _runsForCache = new Map>(); private _lastSeenRevision = 0; + private _canCompleteMigration = true; readonly automations: IObservable; readonly runs: IObservable; @@ -135,8 +137,9 @@ export class AutomationStore extends Disposable implements IAutomationStore { this._now = () => new Date(); const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION)); - const initial = result.kind === 'ledger' ? result.ledger : EMPTY_LEDGER; - if (result.kind === 'ledger') { + const initial = result.kind === 'unsupportedSchema' ? EMPTY_LEDGER : result.ledger; + this._canCompleteMigration = result.kind === 'ledger'; + if (result.kind !== 'unsupportedSchema') { this._lastSeenRevision = result.revision; } this._automations = observableValue(this, initial.automations); @@ -158,6 +161,10 @@ export class AutomationStore extends Disposable implements IAutomationStore { return this._automations.get().find(a => a.id === id); } + canCompleteMigration(): boolean { + return this._canCompleteMigration; + } + runsFor(automationId: string): IObservable { let cached = this._runsForCache.get(automationId); if (!cached) { @@ -460,6 +467,9 @@ export class AutomationStore extends Disposable implements IAutomationStore { if (readResult.kind === 'unsupportedSchema') { throw new Error('Cannot modify automations: storage was written by a newer version'); } + if (readResult.kind === 'invalid') { + throw new Error('Cannot modify automations: persisted storage contains data this version cannot safely interpret'); + } this.acceptLedger(readResult.ledger, readResult.revision); const mutation = mutate(readResult.ledger); @@ -510,9 +520,11 @@ export class AutomationStore extends Disposable implements IAutomationStore { private refreshFromStorage(): void { const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION)); if (result.kind === 'unsupportedSchema') { + this._canCompleteMigration = false; return; } + this._canCompleteMigration = result.kind === 'ledger'; this.acceptLedger(result.ledger, result.revision); } @@ -528,9 +540,11 @@ export class AutomationStore extends Disposable implements IAutomationStore { } if (parsed?.schemaVersion !== CURRENT_SCHEMA_VERSION && !LEGACY_SCHEMA_VERSIONS.has(parsed?.schemaVersion)) { this.logService.warn(`[AutomationService] Unsupported ledger schema version ${parsed?.schemaVersion}; ignoring.`); - return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; + return { kind: 'invalid', ledger: EMPTY_LEDGER, revision: 0 }; } const automations: IAutomationDescriptor[] = []; + // Malformed rows are dropped individually; only structurally invalid ledgers remain read-only. + const invalid = !Array.isArray(parsed.automations) || !Array.isArray(parsed.runs); if (parsed.schemaVersion === CURRENT_SCHEMA_VERSION) { const entries = Array.isArray(parsed.automations) ? parsed.automations : []; for (const entry of entries) { @@ -563,13 +577,13 @@ export class AutomationStore extends Disposable implements IAutomationStore { const validIds = new Set(automations.map(a => a.id)); const serializedRuns = Array.isArray(parsed.runs) ? parsed.runs : []; const runs = serializedRuns - .filter(r => !!r && typeof r === 'object' && validIds.has(r.automationId)) + .filter((run): run is ISerializedAutomationRun => isSerializedAutomationRun(run) && validIds.has(run.automationId)) .map(r => Object.freeze({ ...r, sessionResource: r.sessionResource ? URI.parse(r.sessionResource) : undefined })); const revision = typeof parsed.revision === 'number' ? parsed.revision : 0; - return { kind: 'ledger', ledger: { automations, runs: trimRunsPerAutomation(runs, MAX_RUNS_PER_AUTOMATION) }, revision }; + return { kind: invalid ? 'invalid' : 'ledger', ledger: { automations, runs: trimRunsPerAutomation(runs, MAX_RUNS_PER_AUTOMATION) }, revision }; } catch (err) { this.logService.error('[AutomationService] Failed to parse automations ledger; resetting.', err); - return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; + return { kind: 'invalid', ledger: EMPTY_LEDGER, revision: 0 }; } } @@ -620,6 +634,24 @@ function areAutomationSnapshotsEqual(first: IAutomation, second: IAutomation): b && JSON.stringify(normalizeRuns(first.runs)) === JSON.stringify(normalizeRuns(second.runs)); } +type ISerializedAutomationRun = Omit & { readonly sessionResource?: string }; + +function isSerializedAutomationRun(value: unknown): value is ISerializedAutomationRun { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const run = value as Record; + return typeof run['id'] === 'string' + && typeof run['automationId'] === 'string' + && (run['status'] === 'pending' || run['status'] === 'running' || run['status'] === 'completed' || run['status'] === 'failed') + && (run['trigger'] === 'schedule' || run['trigger'] === 'catch_up' || run['trigger'] === 'manual') + && typeof run['startedAt'] === 'string' + && typeof run['leaderWindowId'] === 'number' + && (run['sessionResource'] === undefined || typeof run['sessionResource'] === 'string') + && (run['completedAt'] === undefined || typeof run['completedAt'] === 'string') + && (run['errorMessage'] === undefined || typeof run['errorMessage'] === 'string'); +} + function deserializeAutomation(s: ISerializedAutomation): IAutomationDescriptor | undefined { const target = deserializeAutomationTarget(s.target); return target ? createAutomationFromSerialized(s, target) : undefined; @@ -719,7 +751,16 @@ function serializeAutomationTarget(target: AutomationTarget): ISerializedAutomat ? { kind: 'quickChat', providerId: target.providerId, sessionTypeId: target.sessionTypeId } : { kind: 'workspace', - folderUri: target.folderUri.toJSON(), + // Serialize explicit components rather than URI.toJSON(). toJSON() emits lazily + // cached fsPath and formatted fields only after they have been accessed, so two URIs + // for the same folder can serialize differently and break snapshot equality checks. + folderUri: { + scheme: target.folderUri.scheme, + authority: target.folderUri.authority, + path: target.folderUri.path, + query: target.folderUri.query, + fragment: target.folderUri.fragment, + }, providerId: target.providerId, sessionTypeId: target.sessionTypeId, isolation: target.isolation, diff --git a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts index 3cfbc13a1894a4..e4444e439f3145 100644 --- a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts +++ b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts @@ -23,6 +23,7 @@ import { ProviderAutomationService } from './providerAutomationService.js'; import { BrowserAutomationStorageService } from './automationStorageService.js'; import { AutomationToolsContribution } from './automationTools.js'; import { IAutomationStorageService } from '../common/automationStorageService.js'; +import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY } from '../../../../platform/agentHost/common/automationMigration.js'; registerSingleton(IAutomationStorageService, BrowserAutomationStorageService, InstantiationType.Delayed); registerSingleton(IAutomationService, ProviderAutomationService, InstantiationType.Delayed); @@ -43,6 +44,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis description: localize('chat.automations.enabled', "Enables the Automations feature: scheduling agent sessions to run on a cadence. When disabled, the Automations entry in the Customizations sidebar, the Automations section in the Customizations editor, and the Automation option in the new-session composer are hidden, and scheduled automations are not dispatched."), included: product.quality !== 'stable', experiment: { mode: 'auto' }, + agentHost: { key: AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY }, }, [CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING]: { type: 'number', @@ -50,8 +52,9 @@ Registry.as(ConfigurationExtensions.Configuration).regis minimum: 1, scope: ConfigurationScope.MACHINE, tags: ['experimental', 'advanced'], - description: localize('chat.automations.runTimeoutMinutes', "Maximum number of minutes a scheduled automation run is allowed to take before the scheduler cancels it and marks it failed. Prevents a single hung run from permanently blocking subsequent scheduled runs."), + description: localize('chat.automations.runTimeoutMinutes', "Maximum number of minutes an automation run is allowed to take before it is ended. Prevents a single hung run from permanently blocking subsequent runs."), included: product.quality !== 'stable', + agentHost: { key: AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY }, }, }, }); diff --git a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index c511444a20387b..92ae3ecaaa7516 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Sequencer } from '../../../../base/common/async.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { disposableTimeout, Sequencer } from '../../../../base/common/async.js'; +import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -20,6 +21,7 @@ interface IAutomationStoreEntry { } const MAX_AUTOMATION_TRANSFER_ATTEMPTS = 3; +const AUTOMATION_MIGRATION_RETRY_DELAY_MS = 30_000; export class ProviderAutomationService extends Disposable implements IAutomationService { @@ -28,6 +30,7 @@ export class ProviderAutomationService extends Disposable implements IAutomation private readonly legacyStore: AutomationService; private readonly providersChanged; private readonly migrationSequencer = new Sequencer(); + private readonly migrationRetry = this._register(new MutableDisposable()); private migrationPromise: Promise = Promise.resolve(); private readonly runsForCache = new Map>(); private staleRunRecoveryGeneration = 0; @@ -126,6 +129,25 @@ export class ProviderAutomationService extends Disposable implements IAutomation return this.findAutomationStore(automationId)?.store.getActiveRunFor(automationId); } + isSchedulingOwnedByHost(automationId: string): boolean { + return this.findAutomationStore(automationId)?.store.isSchedulingOwnedByHost?.(automationId) === true; + } + + canRunAutomation(automationId: string): boolean { + const entry = this.findAutomationStore(automationId); + return entry?.store.canRunAutomation?.(automationId) ?? entry !== undefined; + } + + canUpdateAutomation(automationId: string): boolean { + const entry = this.findAutomationStore(automationId); + return entry?.store.canUpdateAutomation?.(automationId) ?? entry !== undefined; + } + + canDeleteAutomation(automationId: string): boolean { + const entry = this.findAutomationStore(automationId); + return entry?.store.canDeleteAutomation?.(automationId) ?? entry !== undefined; + } + async markStaleRunsFailed(reason: string): Promise { await this.migrationPromise; const stores = this.getStores(); @@ -194,9 +216,13 @@ export class ProviderAutomationService extends Disposable implements IAutomation } await destinationStore.upsertAutomationSnapshot(snapshot); + if (destinationStore.preservesImportedRunHistory === false) { + return; + } const sourceRemoval = await sourceStore.removeAutomationSnapshotIfUnchanged(snapshot); switch (sourceRemoval.kind) { case 'removed': + await destinationStore.acknowledgeAutomationSnapshotImported?.(snapshot); return; case 'missing': await this.rollbackAutomationSnapshotIfUnchanged(destinationStore, snapshot); @@ -229,15 +255,26 @@ export class ProviderAutomationService extends Disposable implements IAutomation } private queueMigration(): void { - this.migrationPromise = this.migrationSequencer.queue(async () => { + this.migrationRetry.clear(); + const migration = this.migrationSequencer.queue(async () => { await this.migrateLegacyAutomations(); + await this.completeProviderMigrations(); const reason = this.staleRunRecoveryReason; if (reason) { await this.recoverStores(this.getStores(), reason, this.staleRunRecoveryGeneration); } - }).catch(error => { - this.logService.error('[ProviderAutomationService] Failed to migrate legacy Automations.', error); }); + this.migrationPromise = migration; + void migration.then( + () => this.migrationRetry.clear(), + error => { + if (this._store.isDisposed || isCancellationError(error)) { + return; + } + this.logService.error(`[ProviderAutomationService] Failed to migrate legacy Automations; retrying in ${AUTOMATION_MIGRATION_RETRY_DELAY_MS}ms.`, error); + this.migrationRetry.value = disposableTimeout(() => this.queueMigration(), AUTOMATION_MIGRATION_RETRY_DELAY_MS); + }, + ); } private async recoverStores(entries: readonly IAutomationStoreEntry[], reason: string, generation: number): Promise { @@ -261,13 +298,24 @@ export class ProviderAutomationService extends Disposable implements IAutomation } private async migrateLegacyAutomations(): Promise { + if (this.legacyStore.canCompleteMigration() === false) { + throw new Error('Legacy Automation storage cannot be migrated safely by this version.'); + } + const failures: Error[] = []; for (const automation of [...this.legacyStore.automations.get()]) { try { await this.migrateLegacyAutomation(automation); } catch (error) { + if (isCancellationError(error) || this._store.isDisposed) { + throw new CancellationError(); + } this.logService.error(`[ProviderAutomationService] Failed to migrate Automation '${automation.id}'.`, error); + failures.push(error instanceof Error ? error : new Error(String(error))); } } + if (failures.length > 0) { + throw new AggregateError(failures, `Failed to migrate ${failures.length} Automation snapshot(s).`); + } } private async migrateLegacyAutomation(initialAutomation: IAutomationDescriptor): Promise { @@ -291,9 +339,13 @@ export class ProviderAutomationService extends Disposable implements IAutomation return; } this.recoveredStores.delete(providerStore); + if (providerStore.preservesImportedRunHistory === false) { + return; + } const sourceRemoval = await this.legacyStore.removeAutomationSnapshotIfUnchanged(snapshot); switch (sourceRemoval.kind) { case 'removed': + await providerStore.acknowledgeAutomationSnapshotImported?.(snapshot); return; case 'missing': if (importResult.kind === 'inserted') { @@ -311,6 +363,13 @@ export class ProviderAutomationService extends Disposable implements IAutomation this.logService.warn(`[ProviderAutomationService] Automation '${snapshot.automation.id}' kept changing during legacy migration; leaving it in legacy storage.`); } + private async completeProviderMigrations(): Promise { + const stores = [...new Set(this.getStores().map(entry => entry.store))]; + for (const store of stores) { + await store.completeMigration?.(); + } + } + private async rollbackAutomationSnapshotIfUnchanged(store: ISessionsProviderAutomations, snapshot: IAutomation): Promise { const result = await store.removeAutomationSnapshotIfUnchanged(snapshot); switch (result.kind) { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index 1f83b823d7ee11..3362dbc6d56902 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { observableValue, waitForState } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -13,11 +14,13 @@ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { createAutomationService } from './automationTestUtils.js'; +import { createAutomationService, TestAutomationStorageService } from './automationTestUtils.js'; import { AutomationTarget, AutomationWorkspaceIsolation, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import type { IAutomationRunClaim } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { AutomationRunner } from '../../browser/automationRunner.js'; +import { AutomationService } from '../../browser/automationService.js'; function hourly(): IAutomationSchedule { return { interval: 'hourly', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; @@ -103,6 +106,33 @@ class RecordingNotificationService extends TestNotificationService { this.infos.push(message); return super.info(message); } + +} + +class ExternalDispatchAutomationService extends AutomationService { + readonly completion = new DeferredPromise(); + cancelCalls = 0; + + override async recordRunStart(automationId: string, trigger: 'manual' | 'schedule' | 'catch_up', leaderWindowId: number): Promise { + const sessionResource = URI.parse('vscode-chat-session://test/external'); + return { + claimed: false, + run: { + id: 'external-run', + automationId, + status: 'running', + trigger, + sessionResource, + startedAt: new Date().toISOString(), + leaderWindowId, + }, + externalDispatch: { + sessionResource, + whenCompleted: this.completion.p, + cancel: () => this.cancelCalls++, + }, + }; + } } function fakeSession(id: string, status = observableValue(`status-${id}`, SessionStatus.Completed), chatStatus = status): ISession { @@ -148,6 +178,69 @@ suite('AutomationRunner', () => { assert.strictEqual(runs[0].leaderWindowId, 99); }); + test('reports an authority-dispatched run as started without creating another session', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new ExternalDispatchAutomationService(storage, log, NullTelemetryService, new TestAutomationStorageService(storage))); + const sessionsMgmt = new FakeSessionsManagementService(); + const runner = new AutomationRunner(service, sessionsMgmt, log, NullTelemetryService, new RecordingNotificationService()); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), target: workspaceTarget() }); + + const operation = runner.runOnce(automation, 'manual', 0); + const dispatch = await operation.whenDispatched; + let completed = false; + void operation.whenCompleted.then(() => completed = true); + await Promise.resolve(); + + assert.deepStrictEqual({ + dispatch: dispatch.kind === 'started' ? { + kind: dispatch.kind, + runId: dispatch.run.id, + automationId: dispatch.run.automationId, + status: dispatch.run.status, + trigger: dispatch.run.trigger, + runSession: dispatch.run.sessionResource?.toString(), + sessionResource: dispatch.sessionResource.toString(), + } : dispatch, + sessionCreateCalls: sessionsMgmt.calls.length, + completed, + }, { + dispatch: { + kind: 'started', + runId: 'external-run', + automationId: automation.id, + status: 'running', + trigger: 'manual', + runSession: 'vscode-chat-session://test/external', + sessionResource: 'vscode-chat-session://test/external', + }, + sessionCreateCalls: 0, + completed: false, + }); + + await service.completion.complete(); + await operation.whenCompleted; + }); + + test('forwards cancellation to an authority-dispatched run', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new ExternalDispatchAutomationService(storage, log, NullTelemetryService, new TestAutomationStorageService(storage))); + const runner = new AutomationRunner(service, new FakeSessionsManagementService(), log, NullTelemetryService, new RecordingNotificationService()); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), target: workspaceTarget() }); + const cancellation = new CancellationTokenSource(); + const operation = runner.runOnce(automation, 'manual', 0, cancellation.token); + await operation.whenDispatched; + + cancellation.cancel(); + await Promise.resolve(); + + assert.strictEqual(service.cancelCalls, 1); + await service.completion.complete(); + await operation.whenCompleted; + cancellation.dispose(); + }); + test('keeps the run active through NeedsInput and records the session before completion', async () => { const { service, sessionsMgmt, runner } = setup(); const status = observableValue('status-s1', SessionStatus.InProgress); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts index 2e4611723ea9b9..176e6db89e2459 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts @@ -56,6 +56,12 @@ class RecordingRecoveryAutomationService extends AutomationService { } } +class HostScheduledAutomationService extends AutomationService { + isSchedulingOwnedByHost(_automationId: string): boolean { + return true; + } +} + interface RecordedRun { readonly automationId: string; readonly trigger: AutomationRunTrigger; @@ -147,6 +153,28 @@ suite('AutomationSchedulerCore', () => { assert.deepStrictEqual(runner.runs, []); }); + test('does not dispatch automations whose provider owns scheduling', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new HostScheduledAutomationService(storage, log, NullTelemetryService, new TestAutomationStorageService(storage))); + const runner = new RecordingRunner(service); + const leader = new FakeLeaderElection(false); + let now = T0; + service.setClockForTesting(() => now); + const core = teardown.add(new AutomationSchedulerCore(service, runner, storage, log, { + leaderElection: leader, + disableAutoTick: true, + now: () => now, + })); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), target: TARGET }); + now = T_PAST_DUE; + + leader.set(true); + await core.waitForPendingRuns(); + + assert.deepStrictEqual(runner.runs, []); + }); + test('on becoming leader, runs catch-up for due automations exactly once', async () => { const { core, runner, service, leader, setNow } = setup(); setNow(T0); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index 0d17a673ac79b2..a56d86a396c410 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -440,12 +440,16 @@ suite('AutomationService', () => { const restored = secondService.getAutomation(created.id); const updated = await secondService.updateAutomation(created.id, { target: workspaceTarget(FOLDER, { kind: 'folder' }) }); + const comparableTarget = (target: AutomationTarget | undefined) => + target && target.kind === 'workspace' + ? { ...target, folderUri: target.folderUri.toString() } + : target; assert.deepStrictEqual({ - restoredTarget: restored?.target, - updatedTarget: updated.target, + restoredTarget: comparableTarget(restored?.target), + updatedTarget: comparableTarget(updated.target), }, { - restoredTarget: workspaceTarget(FOLDER, { kind: 'worktree', branch: 'feature/saved' }), - updatedTarget: workspaceTarget(FOLDER, { kind: 'folder' }), + restoredTarget: comparableTarget(workspaceTarget(FOLDER, { kind: 'worktree', branch: 'feature/saved' })), + updatedTarget: comparableTarget(workspaceTarget(FOLDER, { kind: 'folder' })), }); }); @@ -684,11 +688,23 @@ suite('AutomationService', () => { }); }); - test('reading a corrupt ledger leaves observables empty without throwing', () => { + test('reading a corrupt ledger leaves observables empty and blocks destructive writes', async () => { const storage = teardown.add(new InMemoryStorageService()); storage.store('chat.automations.ledger', 'not json', -1, 1); const service = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); - assert.deepStrictEqual(service.automations.get(), []); + await assert.rejects( + service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget() }), + /cannot safely interpret/, + ); + assert.deepStrictEqual({ + automations: service.automations.get(), + canCompleteMigration: service.canCompleteMigration(), + persisted: storage.get('chat.automations.ledger', -1), + }, { + automations: [], + canCompleteMigration: false, + persisted: 'not json', + }); }); test('drops a malformed schema v3 row without discarding valid rows', () => { @@ -717,13 +733,15 @@ suite('AutomationService', () => { assert.deepStrictEqual({ automationIds: service.automations.get().map(automation => automation.id), runIds: service.runs.get().map(run => run.id), + canCompleteMigration: service.canCompleteMigration(), }, { automationIds: ['keep'], runIds: ['r-keep'], + canCompleteMigration: true, }); }); - test('migrates valid schema v1 records to v3 while dropping malformed targets', async () => { + test('reads valid schema v1 rows and drops malformed rows on rewrite', async () => { const storage = teardown.add(new InMemoryStorageService()); const ledger = { schemaVersion: 1, @@ -754,15 +772,19 @@ suite('AutomationService', () => { }); await service.updateAutomation('keep', { name: 'Updated' }); - const migrated = JSON.parse(storage.get('chat.automations.ledger', -1)!); + const persisted = JSON.parse(storage.get('chat.automations.ledger', -1)!); assert.deepStrictEqual({ - schemaVersion: migrated.schemaVersion, - automationIds: migrated.automations.map((automation: { id: string }) => automation.id), - runIds: migrated.runs.map((run: { id: string }) => run.id), + schemaVersion: persisted.schemaVersion, + automationIds: persisted.automations.map((automation: { id: string }) => automation.id), + keepName: persisted.automations.find((automation: { id: string }) => automation.id === 'keep')?.name, + runIds: persisted.runs.map((run: { id: string }) => run.id), + canCompleteMigration: service.canCompleteMigration(), }, { schemaVersion: 3, automationIds: ['keep', 'quick'], + keepName: 'Updated', runIds: ['r-keep', 'r-quick'], + canCompleteMigration: true, }); }); diff --git a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts index abdcf2d966efa2..f7f50aace94402 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -46,6 +46,14 @@ class FailingTransferAutomationStore extends AutomationStore { } } +class AcknowledgingMigrationAutomationStore extends AutomationStore { + readonly acknowledgedAutomationIds: string[] = []; + + async acknowledgeAutomationSnapshotImported(snapshot: IAutomation): Promise { + this.acknowledgedAutomationIds.push(snapshot.automation.id); + } +} + class ConcurrentlyMutatingMigrationAutomationStore extends AutomationStore { legacyWriter!: AutomationStore; mutation!: 'update' | 'delete' | 'run' | 'continuousUpdate'; @@ -100,7 +108,7 @@ class DestinationDeletingTransferAutomationStore extends AutomationStore { suite('ProviderAutomationService', () => { const teardown = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(legacyRaw?: string, providerRaw?: string, providerFailure?: 'staleRunRecovery' | 'migration' | 'transfer' | 'concurrentMigrationUpdate' | 'concurrentMigrationDelete' | 'concurrentMigrationRun' | 'continuousMigrationUpdate' | 'concurrentTransferRun' | 'destinationDeleteDuringRollback'): { + function createService(legacyRaw?: string, providerRaw?: string, providerFailure?: 'staleRunRecovery' | 'migration' | 'transfer' | 'acknowledgement' | 'concurrentMigrationUpdate' | 'concurrentMigrationDelete' | 'concurrentMigrationRun' | 'continuousMigrationUpdate' | 'concurrentTransferRun' | 'destinationDeleteDuringRollback'): { readonly service: ProviderAutomationService; readonly providerStore: AutomationStore; readonly storage: InMemoryStorageService; @@ -127,6 +135,9 @@ suite('ProviderAutomationService', () => { case 'transfer': providerStore = new FailingTransferAutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage); break; + case 'acknowledgement': + providerStore = new AcknowledgingMigrationAutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage); + break; case 'concurrentMigrationUpdate': case 'concurrentMigrationDelete': case 'concurrentMigrationRun': @@ -238,13 +249,17 @@ suite('ProviderAutomationService', () => { await service.updateAutomation(created.id, { target: legacyTarget }); const legacyLedger = JSON.parse(storage.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION)!); + const finalLegacyTarget = legacyLedger.automations.find((automation: { id: string }) => automation.id === created.id)?.target; assert.deepStrictEqual({ claimRunId: claim.run.id, afterProviderTransfer, finalProviderAutomation: providerStore.getAutomation(created.id), finalProviderRunIds: providerStore.runs.get().map(run => run.id), - finalLegacyTarget: legacyLedger.automations.find((automation: { id: string }) => automation.id === created.id)?.target, + finalLegacyTarget: finalLegacyTarget ? { + ...finalLegacyTarget, + folderUri: URI.revive(finalLegacyTarget.folderUri).toString(), + } : undefined, finalLegacyRunIds: legacyLedger.runs.map((run: { id: string }) => run.id), }, { claimRunId: claim.run.id, @@ -258,7 +273,7 @@ suite('ProviderAutomationService', () => { finalProviderRunIds: [], finalLegacyTarget: { kind: 'workspace', - folderUri: FOLDER.toJSON(), + folderUri: FOLDER.toString(), providerId: 'provider-without-storage', sessionTypeId: 'other', isolation: { kind: 'default' }, @@ -415,12 +430,13 @@ suite('ProviderAutomationService', () => { leaderWindowId: 1, }], }); - const { service, providerStore, storage } = createService(legacy); + const { service, providerStore, storage } = createService(legacy, undefined, 'acknowledgement'); await service.waitForMigrationForTesting(); assert.deepStrictEqual({ automation: providerStore.getAutomation('automation-1'), + acknowledgedAutomationIds: (providerStore as AcknowledgingMigrationAutomationStore).acknowledgedAutomationIds, runIds: providerStore.runs.get().map(run => run.id), legacy: JSON.parse(storage.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION)!), }, { @@ -439,6 +455,7 @@ suite('ProviderAutomationService', () => { lastRunAt: undefined, nextRunAt: undefined, }, + acknowledgedAutomationIds: ['automation-1'], runIds: ['run-1'], legacy: { schemaVersion: 3, revision: 2, automations: [], runs: [] }, }); @@ -883,7 +900,7 @@ suite('ProviderAutomationService', () => { }]); }); - test('continues migrating after an Automation import fails', async () => { + test('continues migrating after an Automation import fails and surfaces the failure', async () => { const createAutomation = (id: string) => ({ id, name: id, @@ -902,7 +919,7 @@ suite('ProviderAutomationService', () => { }); const { service, providerStore, storage } = createService(legacy, undefined, 'migration'); - await service.waitForMigrationForTesting(); + await assert.rejects(service.waitForMigrationForTesting(), /Failed to migrate 1 Automation snapshot/); assert.deepStrictEqual({ providerAutomationIds: providerStore.automations.get().map(automation => automation.id), @@ -912,4 +929,24 @@ suite('ProviderAutomationService', () => { legacyAutomationIds: ['automation-1'], }); }); + + test('does not complete migration from a newer legacy ledger schema', async () => { + const futureLedger = JSON.stringify({ + schemaVersion: 999, + revision: 7, + automations: [{ id: 'future-content' }], + runs: [], + }); + const { service, providerStore, storage } = createService(futureLedger); + + await assert.rejects(service.waitForMigrationForTesting(), /cannot be migrated safely/); + + assert.deepStrictEqual({ + providerAutomations: providerStore.automations.get(), + persisted: storage.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION), + }, { + providerAutomations: [], + persisted: futureLedger, + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 02b3f53a7ceacc..62ede1e843e54f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -39,6 +39,14 @@ The desktop-only Dev Container target is local draft state rather than host-decl The contribution also registers the content and working-directory adapters needed by advertised session types. Runtime startup and shutdown rebind or dispose connection-scoped listeners; consumers must not assume registration means the backend has finished discovery. +## Automations + +Agent Host providers expose Automations through the singleton `ahp-automations://catalog` catalogue when the negotiated host capabilities include `automations`. `AgentHostAutomationStore` projects that authoritative AHP state onto the Sessions automation model; it does not persist definitions or execute a fallback scheduler. `ReconnectableAgentHostAutomationStore` keeps that projection stable across local and remote connection changes and falls back to the legacy store only while the feature is disabled, the host lacks the capability, or migration has not completed. + +Migration imports each legacy definition with canonical `automation/createRequested` actions and waits for authoritative `automation/set` state. Imported definitions identify their initial prompt with `MessageKind.Automation`, preserving automation provenance instead of representing host-triggered execution as a user message. Editor-qualified language-model identifiers are converted to provider-native `ModelSelection.id` values at the AHP boundary while VS Code projection metadata preserves the editor identifier. The host withholds the per-automation `run` operation and rejects execution until every expected resource is present and the durable completion marker is written. Import retries are idempotent and concurrent edits are reconciled before source removal. Failures before a verified item transfer leave its legacy authority intact; failures after transfer retain the durable host definition and archived history for retry. Historical legacy runs are copied to an atomic, read-only local archive before guarded ledger removal because AHP deliberately has no run-history import command. + +After migration, the Agent Host owns manual execution, schedule evaluation, misfire handling, run/session linkage, cancellation, and lifecycle persistence. Run summaries carry host session resources; the provider projection converts them to the local or remote Sessions resource scheme before exposing them to history UI. The browser scheduler consults `isSchedulingOwnedByHost` for each Automation, and the browser runner treats a host-dispatched manual run as started without creating a duplicate session. Connection startup waits for capability negotiation instead of treating an initializing host as a migration failure. The existing `chat.automations.enabled` and `chat.automations.runTimeoutMinutes` settings are mirrored to host root config; disabling Automations removes the `run` operation and stops new schedule claims while leaving durable definitions and already-running sessions intact. + ## Identity The local provider uses: diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts new file mode 100644 index 00000000000000..f1565d6db13dd3 --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -0,0 +1,1202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout, timeout } from '../../../../../base/common/async.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; +import { Disposable, DisposableMap, DisposableStore, toDisposable, type IReference } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, type IObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { hasKey } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { localize } from '../../../../../nls.js'; +import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../platform/agentHost/common/automationMigration.js'; +import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; +import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationCatalogState, type AutomationDefinition, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; +import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationStorageService } from '../../../automations/common/automationStorageService.js'; + +const MUTATION_TIMEOUT_MS = 30_000; +const MIGRATION_POLL_INTERVAL_MS = 50; +const LEGACY_RUN_ARCHIVE_VERSION = 1; +const LEGACY_RUN_ARCHIVE_WRITE_ATTEMPTS = 10; + +export type IAgentHostAutomationConnection = Pick & { + getSubscriptionByChannel( + kind: StateComponents.AutomationCatalog, + channel: string, + owner: string, + ): IReference>; +}; + +interface ISerializedArchivedRun extends Omit { + readonly sessionResource?: string; +} + +interface ILegacyRunArchive { + readonly version: 1; + readonly runs: readonly ISerializedArchivedRun[]; +} + +export interface IAgentHostAutomationBoundaryMapper { + toHost(resource: URI): URI; + fromHost(resource: URI): URI; + resourceSchemeForProvider(provider: string): string; + providerForSessionScheme?(scheme: string): string; + providerForResourceScheme?(scheme: string): string | undefined; +} + +export class AgentHostAutomationStore extends Disposable implements ISessionsProviderAutomations { + + readonly preservesImportedRunHistory = true; + + private readonly _catalogReference: IReference>; + private readonly _catalog: IAgentSubscription; + private readonly _catalogChanged; + private readonly _ready = observableValue(this, false); + private readonly _runsForCache = new Map>(); + private readonly _pendingWaits = this._register(new DisposableMap()); + private _pendingWaitIds = 0; + private readonly _archiveKey: string; + private readonly _archivedRuns; + private _migrationPromise: Promise | undefined; + + readonly automations: IObservable; + readonly runs: IObservable; + + constructor( + private readonly _providerId: string, + private readonly _connection: IAgentHostAutomationConnection, + private readonly _legacySource: ISessionsProviderAutomations | undefined, + private readonly _boundaryMapper: IAgentHostAutomationBoundaryMapper | undefined, + @ILogService private readonly _logService: ILogService, + @IStorageService private readonly _storageService: IStorageService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IAutomationStorageService private readonly _automationStorageService: IAutomationStorageService, + ) { + super(); + this._archiveKey = `agentHostAutomation.legacyRunArchive.${_providerId}`; + this._archivedRuns = observableValue(this, this._loadArchivedRuns()); + this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, this._archiveKey, this._store)(() => { + this._archivedRuns.set(this._loadArchivedRuns(), undefined); + })); + this._catalogReference = this._register(_connection.getSubscriptionByChannel( + StateComponents.AutomationCatalog, + AUTOMATION_CATALOG_URI, + 'AgentHostAutomationStore', + )); + this._catalog = this._catalogReference.object; + this._catalogChanged = observableSignalFromEvent(this, this._catalog.onDidChange); + if (this._catalog.onDidError) { + this._register(this._catalog.onDidError(error => this._logService.error(`[AgentHostAutomationStore] Catalogue subscription failed: ${error.message}`))); + } + this._register(autorun(reader => { + this._catalogChanged.read(reader); + const catalog = this._catalog.value; + if (catalog && !(catalog instanceof Error) + && (isAgentHostAutomationCatalogMigrated(catalog) + || catalog.automations.some(automation => automation.operations.includes(AutomationOperation.Run))) + && !catalog.automations.some(automation => isAgentHostLegacyAutomationImportPending(automation.definition)) + && (!this._legacySource || this._legacySource.automations.read(reader).length === 0) + && !this._migrationPromise + && !this._ready.read(reader)) { + this._ready.set(true, undefined); + } + })); + this.automations = derived(this, reader => { + this._catalogChanged.read(reader); + if (!this._ready.read(reader)) { + return distinctById([ + ...(this._legacySource?.automations.read(reader) ?? []), + ...this._projectAutomations(), + ]); + } + return this._projectAutomations(); + }); + this.runs = derived(this, reader => { + this._catalogChanged.read(reader); + if (!this._ready.read(reader)) { + return distinctById([ + ...(this._legacySource?.runs.read(reader) ?? []), + ...this._archivedRuns.read(reader), + ]).sort((first, second) => second.startedAt.localeCompare(first.startedAt)); + } + return distinctById([...this._projectRuns(), ...this._archivedRuns.read(reader)]) + .sort((first, second) => second.startedAt.localeCompare(first.startedAt)); + }); + } + + getAutomation(id: string): IAutomationDescriptor | undefined { + return this._ready.get() + ? this._projectAutomation(this._findAutomationState(id)) + : this._legacySource?.getAutomation(id) ?? this._projectAutomation(this._findAutomationState(id)); + } + + isSchedulingOwnedByHost(automationId: string): boolean { + if (!this._ready.get()) { + return false; + } + const state = this._findAutomationState(automationId); + return state !== undefined + && !isAgentHostLegacyAutomationImportPending(state.definition) + && state.operations.includes(AutomationOperation.Run); + } + + canRunAutomation(automationId: string): boolean { + return this._operationAvailable(automationId, AutomationOperation.Run); + } + + canUpdateAutomation(automationId: string): boolean { + return this._operationAvailable(automationId, AutomationOperation.Update); + } + + canDeleteAutomation(automationId: string): boolean { + return this._operationAvailable(automationId, AutomationOperation.Remove); + } + + runsFor(automationId: string): IObservable { + let result = this._runsForCache.get(automationId); + if (!result) { + result = derived(this, reader => this.runs.read(reader).filter(run => run.automationId === automationId)); + this._runsForCache.set(automationId, result); + } + return result; + } + + async createAutomation(options: ICreateAutomationOptions, mutationGuard?: AutomationMutationGuard): Promise { + await this._waitForMigrationBeforeMutation(); + if (!this._ready.get() && this._legacySource) { + return this._legacySource.createAutomation(options, mutationGuard); + } + mutationGuard?.(); + const now = new Date(); + const descriptor: IAutomationDescriptor = { + id: generateUuid(), + name: options.name, + prompt: options.prompt, + schedule: options.schedule, + target: options.target, + modelId: options.modelId, + mode: options.mode, + permissionLevel: options.permissionLevel, + enabled: options.enabled ?? true, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + const state = await this._createDescriptor(descriptor); + return this._requireProjectedAutomation(state); + } + + async updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise { + await this._waitForMigrationBeforeMutation(); + if (!this._ready.get() && this._legacySource?.getAutomation(id)) { + return this._legacySource.updateAutomation(id, patch); + } + this._requireOperation(id, AutomationOperation.Update); + const current = this._requireAutomation(id); + const updated = this._applyPatch(current, patch); + const state = await this._replaceDescriptor(updated); + return this._requireProjectedAutomation(state); + } + + async updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, expected: IAutomationDescriptor, mutationGuard?: AutomationMutationGuard): Promise { + await this._waitForMigrationBeforeMutation(); + if (!this._ready.get() && this._legacySource?.getAutomation(id)) { + return this._legacySource.updateAutomationIfUnchanged(id, patch, expected, mutationGuard); + } + mutationGuard?.(); + const current = this.getAutomation(id); + if (!current || serializeAutomationEditableState(current) !== serializeAutomationEditableState(expected)) { + return { kind: 'conflict', current }; + } + return { kind: 'updated', automation: await this.updateAutomation(id, patch) }; + } + + async deleteAutomation(id: string, mutationGuard?: AutomationMutationGuard): Promise { + await this._waitForMigrationBeforeMutation(); + if (!this._ready.get() && this._legacySource?.getAutomation(id)) { + return this._legacySource.deleteAutomation(id, mutationGuard); + } + this._requireOperation(id, AutomationOperation.Remove); + mutationGuard?.(); + const resource = automationResource(id); + if (!this._findAutomationState(id)) { + return; + } + await this._dispatchAndWait( + { type: ActionType.AutomationRemoved, resource }, + catalog => !catalog.automations.some(automation => automation.resource === resource), + ); + this._runsForCache.delete(id); + } + + async importAutomationSnapshot(snapshot: IAutomation): Promise { + return this._importAutomationSnapshot(snapshot, true); + } + + private async _importAutomationSnapshot(snapshot: IAutomation, importPending: boolean): Promise { + const existing = this._findAutomationState(snapshot.automation.id); + if (existing) { + const current = this._requireProjectedAutomation(existing); + const expected = this._canonicalDescriptor(snapshot.automation, existing); + if (serializeAutomationEditableState(current) !== serializeAutomationEditableState(expected)) { + if (isAgentHostLegacyAutomationImport(existing.definition)) { + await this._replaceDescriptor(snapshot.automation, true, importPending); + await this._archiveRuns(snapshot.runs); + return { kind: 'alreadyPresent' }; + } + return { kind: 'conflict', current: { automation: current, runs: this._projectRunsFor(existing.resource) } }; + } + // Editable state matches, but if the caller is staging pending and + // the existing definition is not already pending, re-dispatch so the + // meta flag lands. Otherwise a retry after a lost dispatch would + // leave Run authority granted on a not-yet-drained legacy row. + if (importPending && !isAgentHostLegacyAutomationImportPending(existing.definition)) { + await this._replaceDescriptor(snapshot.automation, true, importPending); + } + await this._archiveRuns(snapshot.runs); + return { kind: 'alreadyPresent' }; + } + await this._createDescriptor(snapshot.automation, true, importPending); + await this._archiveRuns(snapshot.runs); + this._logService.info(`[AgentHostAutomationStore] Migrated Automation definition: resource=${automationResource(snapshot.automation.id)}, legacyRunsRetained=${snapshot.runs.length}.`); + return { kind: 'inserted' }; + } + + async upsertAutomationSnapshot(snapshot: IAutomation): Promise { + if (this._findAutomationState(snapshot.automation.id)) { + await this._replaceDescriptor(snapshot.automation, true, true); + } else { + await this._createDescriptor(snapshot.automation, true, true); + } + await this._archiveRuns(snapshot.runs); + } + + async removeAutomationSnapshotIfUnchanged(expected: IAutomation): Promise { + const current = this._findAutomationState(expected.automation.id); + if (!current) { + return { kind: 'missing' }; + } + const projected = this._requireProjectedAutomation(current); + const canonicalExpected = this._canonicalDescriptor(expected.automation, current); + if (serializeAutomationEditableState(projected) !== serializeAutomationEditableState(canonicalExpected)) { + return { kind: 'conflict', current: { automation: projected, runs: this._projectRunsFor(current.resource) } }; + } + await this.deleteAutomation(expected.automation.id); + return { kind: 'removed' }; + } + + async acknowledgeAutomationSnapshotImported(snapshot: IAutomation): Promise { + const current = this._findAutomationState(snapshot.automation.id); + if (!current || !isAgentHostLegacyAutomationImportPending(current.definition)) { + return; + } + await this._clearImportPending(snapshot.automation.id); + } + + async recordRunStart(automationId: string, trigger: AutomationRunTrigger, _leaderWindowId: number): Promise { + if (!this._ready.get() && this._legacySource?.getAutomation(automationId)) { + return this._legacySource.recordRunStart(automationId, trigger, _leaderWindowId); + } + if (trigger !== 'manual') { + throw new Error('Scheduled Automation execution is owned by the Agent Host.'); + } + this._requireOperation(automationId, AutomationOperation.Run); + const activeRun = this.getActiveRunFor(automationId); + if (activeRun) { + return { claimed: false, run: activeRun }; + } + const result = await this._connection.runAutomation({ + channel: AUTOMATION_CATALOG_URI, + automation: automationResource(automationId), + requestId: generateUuid(), + }); + const catalog = await this._waitForCatalog(state => state.automations.some(automation => automation.runs.some(run => + run.resource === result.resource && (run.primarySession !== undefined || isTerminalRun(run)) + ))); + const run = catalog.automations.flatMap(automation => automation.runs).find(candidate => candidate.resource === result.resource); + if (!run) { + throw new Error(`Automation run did not appear in the authoritative catalogue: ${result.resource}`); + } + const projectedRun = this._projectRun(run); + return { + claimed: false, + run: projectedRun, + externalDispatch: { + sessionResource: projectedRun.sessionResource, + whenCompleted: this._waitForCatalog(state => state.automations.some(automation => automation.runs.some(candidate => + candidate.resource === result.resource && isTerminalRun(candidate) + )), undefined, null).then(() => undefined), + ...(this._connection.initializeResult.get()?.automations?.runCancellation ? { + cancel: () => this._connection.dispatch(result.resource, { type: ActionType.AutomationRunCancelRequested }), + } : {}), + }, + }; + } + + // Projects an Agent Host session resource into the editor-facing provider scheme. + private _projectSessionResource(resource: string): URI { + const session = URI.parse(resource); + const provider = this._boundaryMapper?.providerForSessionScheme?.(session.scheme) ?? session.scheme; + const resourceScheme = this._boundaryMapper?.resourceSchemeForProvider(provider); + return resourceScheme ? session.with({ scheme: resourceScheme }) : session; + } + + async updateRun(runId: string, _patch: IUpdateAutomationRunOptions): Promise { + if (!this._ready.get() && this._legacySource?.runs.get().some(run => run.id === runId)) { + return this._legacySource.updateRun(runId, _patch); + } + return this.runs.get().find(run => run.id === runId); + } + + async deleteRun(runId: string): Promise { + if (!this._ready.get() && this._legacySource?.runs.get().some(run => run.id === runId)) { + return this._legacySource.deleteRun(runId); + } + throw new Error('Automation run history is owned by the Agent Host.'); + } + + getActiveRunFor(automationId: string): IAutomationRun | undefined { + if (!this._ready.get() && this._legacySource?.getAutomation(automationId)) { + return this._legacySource.getActiveRunFor(automationId); + } + return this.runs.get().find(run => run.automationId === automationId && (run.status === 'pending' || run.status === 'running')); + } + + async markStaleRunsFailed(reason: string): Promise { + if (!this._ready.get() && this._legacySource) { + return this._legacySource.markStaleRunsFailed(reason); + } + } + + async completeMigration(): Promise { + if (this._ready.get()) { + return; + } + if (this._migrationPromise) { + return this._migrationPromise; + } + const migration = this._completeMigration(); + this._migrationPromise = migration; + try { + await migration; + } finally { + if (this._migrationPromise === migration) { + this._migrationPromise = undefined; + } + } + } + + private async _completeMigration(): Promise { + const startedAt = Date.now(); + const source = this._legacySource; + const discovered = source ? [...source.automations.get()] : []; + this._logService.info(`[AgentHostAutomationStore] Automation migration started: discovered=${discovered.length}.`); + publishAutomationMigration(this._telemetryService, { + outcome: 'started', + discoveredCount: discovered.length, + migratedCount: 0, + failedCount: 0, + durationMs: 0, + }); + let migratedCount = 0; + let failedCount = 0; + try { + if (source?.canCompleteMigration?.() === false) { + throw new Error('Legacy Automation storage cannot be migrated safely by this version.'); + } + const failures: Error[] = []; + for (const automation of discovered) { + try { + await this._migrateLegacySourceAutomation(automation); + migratedCount++; + } catch (error) { + if (isCancellationError(error) || this._store.isDisposed) { + throw new CancellationError(); + } + const failure = error instanceof Error ? error : new Error(String(error)); + failures.push(failure); + failedCount++; + this._logService.error(`[AgentHostAutomationStore] Automation migration item failed: resource=${automationResource(automation.id)}, error=${failure.message}`); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, `Failed to migrate ${failures.length} Agent Host Automation definition(s).`); + } + + this._requireLegacySourceDrained(); + await this._waitForCatalog(() => true); + const resources = discovered.map(automation => automationResource(automation.id)); + this._connection.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { + [AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY]: { + version: 1, + status: 'complete', + resources, + }, + }, + }); + await this._waitForMigrationCompletion(); + this._requireLegacySourceDrained(); + // Sweep any stragglers whose pending flag never cleared. This + // covers reconnect races and cross-provider transfers that stage + // pending without a subsequent acknowledgement path. + await this._drainPendingImports(); + this._ready.set(true, undefined); + const durationMs = Date.now() - startedAt; + this._logService.info(`[AgentHostAutomationStore] Automation migration completed: discovered=${discovered.length}, migrated=${resources.length}, failed=0, durationMs=${durationMs}.`); + publishAutomationMigration(this._telemetryService, { + outcome: 'completed', + discoveredCount: discovered.length, + migratedCount: resources.length, + failedCount: 0, + durationMs, + }); + } catch (error) { + if (isCancellationError(error)) { + throw error; + } + if (error instanceof AggregateError) { + failedCount = Math.max(failedCount, error.errors.length); + } + const durationMs = Date.now() - startedAt; + this._logService.error(`[AgentHostAutomationStore] Automation migration failed: discovered=${discovered.length}, migrated=${migratedCount}, failed=${failedCount}, durationMs=${durationMs}, error=${error instanceof Error ? error.message : String(error)}.`); + publishAutomationMigration(this._telemetryService, { + outcome: 'failed', + discoveredCount: discovered.length, + migratedCount, + failedCount, + durationMs, + }); + throw error; + } + } + + private async _waitForMigrationBeforeMutation(): Promise { + const migration = this._migrationPromise; + if (migration) { + await migration; + } + } + + private _requireLegacySourceDrained(): void { + const remaining = this._legacySource?.automations.get().length ?? 0; + if (remaining > 0) { + throw new Error(`Automation migration source changed during migration; ${remaining} definition(s) remain.`); + } + } + + private async _migrateLegacySourceAutomation(initialAutomation: IAutomationDescriptor): Promise { + const source = this._legacySource; + if (!source) { + return; + } + let snapshot: IAutomation = { automation: initialAutomation, runs: source.runsFor(initialAutomation.id).get() }; + for (let attempt = 0; attempt < 3; attempt++) { + const result = await this._importAutomationSnapshot(snapshot, true); + if (result.kind === 'conflict') { + throw new Error(`Automation conflicts with the Agent Host catalogue: ${automationResource(initialAutomation.id)}`); + } + const removal = await source.removeAutomationSnapshotIfUnchanged(snapshot); + if (removal.kind === 'removed' || removal.kind === 'missing') { + // Legacy row is durably gone. Clear the pending flag so the + // host can grant Run authority now that no other authority + // owns the source. + await this._clearImportPending(initialAutomation.id); + return; + } + snapshot = removal.current; + } + throw new Error(`Automation kept changing while migrating: ${automationResource(initialAutomation.id)}`); + } + + private async _clearImportPending(automationId: string): Promise { + const current = this._findAutomationState(automationId); + if (!current || !isAgentHostLegacyAutomationImportPending(current.definition)) { + return; + } + const projected = this._projectAutomation(current); + if (!projected) { + return; + } + await this._replaceDescriptor(projected, isAgentHostLegacyAutomationImport(current.definition), false); + } + + private async _drainPendingImports(): Promise { + const catalog = this._catalog.value; + if (!catalog || catalog instanceof Error) { + return; + } + const failures: Error[] = []; + const pending = catalog.automations.filter(automation => isAgentHostLegacyAutomationImportPending(automation.definition)); + for (const automation of pending) { + if (this._store.isDisposed) { + throw new CancellationError(); + } + const id = automationId(automation.resource); + const legacyEntry = this._legacySource?.getAutomation(id); + try { + if (legacyEntry) { + await this._migrateLegacySourceAutomation(legacyEntry); + } else { + // Stranded pending row: the legacy source row was removed + // by another authority (e.g., a cross-provider transfer) + // without acknowledging the AHP import. Clear the flag so + // the host can start scheduling the automation. + await this._clearImportPending(id); + } + } catch (error) { + if (isCancellationError(error) || this._store.isDisposed) { + throw new CancellationError(); + } + const failure = error instanceof Error ? error : new Error(String(error)); + failures.push(failure); + this._logService.error(`[AgentHostAutomationStore] Failed to drain pending Automation import: id=${id}, error=${failure.message}`); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, `Failed to drain ${failures.length} pending Agent Host Automation import(s).`); + } + } + + // Projects the Agent Host catalogue into editor-facing Automation descriptors. + private _projectAutomations(): IAutomationDescriptor[] { + const catalog = this._catalog.value; + if (!catalog || catalog instanceof Error) { + return []; + } + return catalog.automations + .map(automation => this._projectAutomation(automation)) + .filter((automation): automation is IAutomationDescriptor => automation !== undefined) + .sort((first, second) => second.createdAt.localeCompare(first.createdAt)); + } + + // Projects Agent Host run summaries into editor-facing Automation runs. + private _projectRuns(): IAutomationRun[] { + const catalog = this._catalog.value; + if (!catalog || catalog instanceof Error) { + return []; + } + return catalog.automations + .flatMap(automation => automation.runs) + .map(run => this._projectRun(run)) + .sort((first, second) => second.startedAt.localeCompare(first.startedAt)); + } + + // Projects one Agent Host Automation's run summaries into editor-facing runs. + private _projectRunsFor(resource: string): IAutomationRun[] { + return this._findAutomationStateByResource(resource)?.runs.map(run => this._projectRun(run)) ?? []; + } + + // Projects Agent Host Automation state into the editor-facing Automation model. + private _projectAutomation(state: AutomationState | undefined): IAutomationDescriptor | undefined { + if (!state) { + return undefined; + } + const target = this._projectTarget(state.definition); + if (!target) { + this._logService.warn(`[AgentHostAutomationStore] Cannot project Automation with no provider: resource=${state.resource}.`); + return undefined; + } + const config = state.definition.session.config; + const newestRun = state.runs[0]; + return { + id: automationId(state.resource), + name: state.definition.title, + prompt: state.definition.message.text, + schedule: projectSchedule(state.definition.triggers), + target, + modelId: this._projectModelId(state.definition.session.model?.id, state.definition.session.provider), + mode: readString(config?.[SessionConfigKey.Mode]), + permissionLevel: readString(config?.[SessionConfigKey.AutoApprove]), + enabled: state.definition.enabled, + createdAt: state.createdAt, + updatedAt: state.modifiedAt, + lastRunAt: newestRun?.lifecycle.createdAt, + nextRunAt: state.nextRunAt, + }; + } + + // Projects an Agent Host session template into an editor-facing Automation target. + private _projectTarget(definition: AutomationDefinition): AutomationTarget | undefined { + const provider = definition.session.provider; + const directory = definition.session.workingDirectories?.[0]; + if (!directory) { + return provider ? { kind: 'quickChat', providerId: this._providerId, sessionTypeId: provider } : undefined; + } + const config = definition.session.config; + const isolation = config?.[SessionConfigKey.Isolation]; + return { + kind: 'workspace', + folderUri: this._boundaryMapper?.fromHost(URI.parse(directory)) ?? URI.parse(directory), + providerId: this._providerId, + sessionTypeId: provider, + isolation: isolation === 'worktree' + ? { kind: 'worktree', branch: readString(config?.[SessionConfigKey.Branch]) ?? '' } + : isolation === 'folder' + ? { kind: 'folder' } + : { kind: 'default' }, + }; + } + + // Projects an Agent Host run summary into the editor-facing Automation run model. + private _projectRun(run: AutomationRunSummary): IAutomationRun { + const lifecycle = run.lifecycle; + const primarySession = run.primarySession ? this._projectSessionResource(run.primarySession) : undefined; + return { + id: automationRunId(run.resource), + automationId: automationId(run.automation), + status: lifecycle.status === AutomationRunStatus.Cancelled ? 'failed' : lifecycle.status, + trigger: run.origin.kind === AutomationRunOriginKind.Manual + ? 'manual' + : run.origin.catchUp ? 'catch_up' : 'schedule', + sessionResource: primarySession, + startedAt: lifecycle.status === AutomationRunStatus.Pending ? lifecycle.createdAt : lifecycle.startedAt ?? lifecycle.createdAt, + completedAt: lifecycle.status === AutomationRunStatus.Completed || lifecycle.status === AutomationRunStatus.Failed || lifecycle.status === AutomationRunStatus.Cancelled + ? lifecycle.completedAt + : undefined, + errorMessage: lifecycle.status === AutomationRunStatus.Failed + ? lifecycle.error.message + : lifecycle.status === AutomationRunStatus.Cancelled + ? localize('agentHostAutomation.cancelled', "Cancelled") + : undefined, + leaderWindowId: 0, + }; + } + + private _findAutomationState(id: string): AutomationState | undefined { + return this._findAutomationStateByResource(automationResource(id)); + } + + private _findAutomationStateByResource(resource: string): AutomationState | undefined { + const catalog = this._catalog.value; + return catalog && !(catalog instanceof Error) + ? catalog.automations.find(automation => automation.resource === resource) + : undefined; + } + + private _requireAutomation(id: string): IAutomationDescriptor { + const automation = this.getAutomation(id); + if (!automation) { + throw new Error(`Automation does not exist: ${id}`); + } + return automation; + } + + private _operationAvailable(id: string, operation: AutomationOperation): boolean { + if (!this._ready.get() && this._legacySource?.getAutomation(id)) { + return true; + } + return this._findAutomationState(id)?.operations.includes(operation) === true; + } + + private _requireOperation(id: string, operation: AutomationOperation): void { + if (!this._operationAvailable(id, operation)) { + throw new Error(`Automation operation '${operation}' is not available: ${id}`); + } + } + + private _requireProjectedAutomation(state: AutomationState): IAutomationDescriptor { + const automation = this._projectAutomation(state); + if (!automation) { + throw new Error(`Automation cannot be represented by the compatibility view: ${state.resource}`); + } + return automation; + } + + private async _createDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { + const resource = automationResource(descriptor.id); + const definition = this._definitionFromDescriptor(descriptor, undefined, imported, importPending); + const state = await this._dispatchAndWait( + { type: ActionType.AutomationCreateRequested, resource, definition }, + catalog => catalog.automations.some(automation => automation.resource === resource), + ); + if (!state) { + throw new Error(`Automation create completed without authoritative state: ${resource}`); + } + return state; + } + + private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { + const resource = automationResource(descriptor.id); + const current = this._findAutomationState(descriptor.id); + if (!current) { + throw new Error(`Automation does not exist: ${descriptor.id}`); + } + const definition = this._definitionFromDescriptor(descriptor, current.definition, imported, importPending); + const expected = this._requireProjectedAutomation({ ...current, definition }); + const state = await this._dispatchAndWait( + { + type: ActionType.AutomationUpdateRequested, + resource, + changes: { + title: definition.title, + message: definition.message, + session: definition.session, + enabled: definition.enabled, + triggers: definition.triggers, + _meta: definition._meta, + }, + }, + catalog => { + const state = catalog.automations.find(automation => automation.resource === resource); + const projected = this._projectAutomation(state); + if (projected === undefined + || serializeAutomationEditableState(projected) !== serializeAutomationEditableState(expected)) { + return false; + } + // The pending flag lives on definition._meta, which the + // editable-state comparison does not observe. Force the wait + // to also see the intended pending state so a caller that + // depends on the flag being (un)set doesn't race the host. + if (importPending === true) { + return isAgentHostLegacyAutomationImportPending(state!.definition); + } + if (importPending === false) { + return !isAgentHostLegacyAutomationImportPending(state!.definition); + } + return true; + }, + ); + if (!state) { + throw new Error(`Automation update completed without authoritative state: ${resource}`); + } + return state; + } + + private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean): AutomationDefinition { + const config = { ...existing?.session.config }; + const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(descriptor.modelId); + setOptional(config, SessionConfigKey.Mode, descriptor.mode); + setOptional(config, SessionConfigKey.AutoApprove, descriptor.permissionLevel); + if (descriptor.target.kind === 'workspace') { + setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); + setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); + } else { + setOptional(config, SessionConfigKey.Isolation, undefined); + setOptional(config, SessionConfigKey.Branch, undefined); + } + const meta: Record = { + ...existing?._meta, + ...((imported || isAgentHostLegacyAutomationImport(existing)) ? { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY]: true } : {}), + }; + if (importPending === true) { + meta[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY] = true; + } else if (importPending === false) { + delete meta[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]; + } + return { + title: descriptor.name, + message: { text: descriptor.prompt, origin: { kind: MessageKind.Automation } }, + session: { + provider, + model: descriptor.modelId ? { id: this._toHostModelId(descriptor.modelId, provider) } : undefined, + workingDirectories: descriptor.target.kind === 'workspace' + ? [(this._boundaryMapper?.toHost(descriptor.target.folderUri) ?? descriptor.target.folderUri).toString()] + : undefined, + config: Object.keys(config).length > 0 ? config : undefined, + }, + enabled: descriptor.enabled, + triggers: scheduleTrigger(descriptor.schedule), + _meta: Object.keys(meta).length > 0 ? meta : undefined, + }; + } + + private _toHostModelId(modelId: string, provider: string | undefined): string { + const resourceScheme = provider ? this._boundaryMapper?.resourceSchemeForProvider(provider) : undefined; + const prefix = resourceScheme ? `${resourceScheme}:` : undefined; + if (prefix && modelId.startsWith(prefix)) { + return modelId.slice(prefix.length); + } + return modelId; + } + + private _providerFromModelId(modelId: string | undefined): string | undefined { + if (!modelId) { + return undefined; + } + const separator = modelId.indexOf(':'); + return separator > 0 ? this._boundaryMapper?.providerForResourceScheme?.(modelId.slice(0, separator)) : undefined; + } + + // Projects an Agent Host model identifier into the editor-facing provider namespace. + private _projectModelId(modelId: string | undefined, provider: string | undefined): string | undefined { + if (!modelId) { + return undefined; + } + const resourceScheme = provider ? this._boundaryMapper?.resourceSchemeForProvider(provider) : undefined; + const prefix = resourceScheme ? `${resourceScheme}:` : undefined; + return prefix && !modelId.startsWith(prefix) ? `${prefix}${modelId}` : modelId; + } + + private _canonicalDescriptor(descriptor: IAutomationDescriptor, state: AutomationState): IAutomationDescriptor { + const definition = this._definitionFromDescriptor(descriptor, state.definition); + return this._requireProjectedAutomation({ ...state, definition }); + } + + private _applyPatch(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): IAutomationDescriptor { + const now = new Date(); + const schedule = patch.schedule ?? current.schedule; + const enabled = patch.enabled ?? current.enabled; + const target = patch.target ?? current.target; + const targetAuthorityChanged = patch.target !== undefined + && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); + return { + ...current, + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}), + schedule, + target, + modelId: patch.modelId === null + ? undefined + : patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId), + mode: patch.mode === null ? undefined : patch.mode ?? current.mode, + permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? current.permissionLevel, + enabled, + updatedAt: now.toISOString(), + }; + } + + private async _dispatchAndWait( + action: Parameters[1] & { readonly resource: string }, + predicate: (catalog: AutomationCatalogState) => boolean, + ): Promise { + await this._waitForCatalog(() => true); + const result = this._waitForCatalog(predicate, action); + this._connection.dispatch(AUTOMATION_CATALOG_URI, action); + const catalog = await result; + const state = catalog.automations.find(automation => automation.resource === action.resource); + return state; + } + + private _waitForCatalog( + predicate: (catalog: AutomationCatalogState) => boolean, + action?: { readonly type: ActionType; readonly resource: string }, + timeoutMs: number | null = MUTATION_TIMEOUT_MS, + ): Promise { + if (this._store.isDisposed) { + return Promise.reject(new CancellationError()); + } + const current = this._catalog.value; + if (current instanceof Error) { + return Promise.reject(current); + } + if (current && predicate(current)) { + return Promise.resolve(current); + } + return new Promise((resolve, reject) => { + const store = new DisposableStore(); + const waitId = ++this._pendingWaitIds; + let settled = false; + this._pendingWaits.set(waitId, store); + store.add(toDisposable(() => { + if (!settled) { + settled = true; + reject(new CancellationError()); + } + })); + const finish = (result: AutomationCatalogState | Error) => { + if (settled) { + return; + } + settled = true; + this._pendingWaits.deleteAndDispose(waitId); + if (result instanceof Error) { + reject(result); + } else { + resolve(result); + } + }; + const check = () => { + const catalog = this._catalog.value; + if (catalog instanceof Error) { + finish(catalog); + } else if (catalog && predicate(catalog)) { + finish(catalog); + } + }; + store.add(this._catalog.onDidChange(check)); + if (this._catalog.onDidError) { + store.add(this._catalog.onDidError(error => finish(error))); + } + if (action) { + store.add(this._connection.onDidAction(envelope => { + if (envelope.channel === AUTOMATION_CATALOG_URI + && envelope.rejectionReason + && envelope.action.type === action.type + && hasKey(envelope.action, { resource: true }) + && envelope.action.resource === action.resource) { + finish(new Error(envelope.rejectionReason)); + } + })); + } + if (timeoutMs !== null) { + store.add(disposableTimeout(() => finish(new Error(`Timed out waiting for authoritative Automation state after ${timeoutMs}ms.`)), timeoutMs)); + } + check(); + }); + } + + private async _waitForMigrationCompletion(): Promise { + const deadline = Date.now() + MUTATION_TIMEOUT_MS; + let lastError: Error | undefined; + while (Date.now() < deadline) { + if (this._store.isDisposed) { + throw new CancellationError(); + } + try { + await this._connection.listAutomationTriggerDefinitions({ channel: ROOT_STATE_URI }); + return; + } catch (error) { + if (isCancellationError(error) || this._store.isDisposed) { + throw new CancellationError(); + } + lastError = error instanceof Error ? error : new Error(String(error)); + await timeout(MIGRATION_POLL_INTERVAL_MS); + } + } + if (this._store.isDisposed) { + throw new CancellationError(); + } + throw lastError ?? new Error('Timed out waiting for Agent Host Automation migration completion.'); + } + + private _loadArchivedRuns(): readonly IAutomationRun[] { + const raw = this._storageService.get(this._archiveKey, StorageScope.APPLICATION); + if (!raw) { + return []; + } + const parsed = parseArchivedRuns(raw); + if (parsed.kind === 'unsupported') { + this._logService.error(`[AgentHostAutomationStore] Ignoring legacy run archive with unsupported version: key=${this._archiveKey}, version=${parsed.version}.`); + return []; + } + if (parsed.kind === 'invalid') { + this._logService.error(`[AgentHostAutomationStore] Ignoring invalid legacy run archive: key=${this._archiveKey}, error=${parsed.error}.`); + return []; + } + if (parsed.droppedRuns > 0) { + this._logService.warn(`[AgentHostAutomationStore] Dropped ${parsed.droppedRuns} malformed run(s) from legacy run archive: key=${this._archiveKey}.`); + } + return parsed.runs; + } + + private async _archiveRuns(runs: readonly IAutomationRun[]): Promise { + if (runs.length === 0) { + return; + } + let raw = await this._automationStorageService.read(this._archiveKey); + for (let attempt = 0; attempt < LEGACY_RUN_ARCHIVE_WRITE_ATTEMPTS; attempt++) { + let current: readonly IAutomationRun[] = []; + if (raw !== undefined) { + const parsed = parseArchivedRuns(raw); + if (parsed.kind === 'unsupported') { + throw new Error(`Cannot update legacy Automation run archive with unsupported version: key=${this._archiveKey}, version=${parsed.version}.`); + } + if (parsed.kind === 'invalid') { + this._logService.error(`[AgentHostAutomationStore] Replacing invalid legacy run archive: key=${this._archiveKey}, error=${parsed.error}.`); + } else { + current = parsed.runs; + if (parsed.droppedRuns > 0) { + this._logService.warn(`[AgentHostAutomationStore] Dropping ${parsed.droppedRuns} malformed run(s) while repairing legacy run archive: key=${this._archiveKey}.`); + } + } + } + const merged = distinctById([...runs, ...current]); + const archive: ILegacyRunArchive = { + version: LEGACY_RUN_ARCHIVE_VERSION, + runs: merged.map(run => ({ + ...run, + sessionResource: run.sessionResource?.toString(), + })), + }; + const next = JSON.stringify(archive); + const result = await this._automationStorageService.compareAndSwap(this._archiveKey, raw, next); + if (result.swapped) { + this._archivedRuns.set(merged, undefined); + return; + } + raw = result.currentValue; + } + throw new Error(`Legacy Automation run archive kept changing while it was being updated: ${this._archiveKey}`); + } +} + +function automationResource(id: string): string { + return URI.from({ scheme: 'ahp-automation', path: `/${id}` }).toString(); +} + +function automationId(resource: string): string { + return URI.parse(resource).path.split('/').filter(Boolean).at(-1) ?? resource; +} + +function automationRunId(resource: string): string { + return URI.parse(resource).path.split('/').filter(Boolean).at(-1) ?? resource; +} + +function isTerminalRun(run: AutomationRunSummary): boolean { + return run.lifecycle.status === AutomationRunStatus.Completed + || run.lifecycle.status === AutomationRunStatus.Failed + || run.lifecycle.status === AutomationRunStatus.Cancelled; +} + +// Projects Agent Host triggers into the editor-facing schedule model. +function projectSchedule(triggers: AutomationDefinition['triggers']): IAutomationSchedule { + const trigger = triggers.find(trigger => trigger.kind === AutomationTriggerKind.Schedule); + if (!trigger) { + return { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; + } + const [minuteValue, hourValue, dayOfMonth, month, dayValue, ...remaining] = trigger.schedule.expression.trim().split(/\s+/); + if (remaining.length > 0 || dayOfMonth !== '*' || month !== '*') { + return { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; + } + const scheduleMinute = parseCronValue(minuteValue, 0, 59); + if (scheduleMinute === undefined) { + return { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; + } + if (hourValue === '*' && dayValue === '*') { + return { interval: 'hourly', scheduleHour: 0, scheduleMinute, scheduleDay: 0 }; + } + const scheduleHour = parseCronValue(hourValue, 0, 23); + if (scheduleHour === undefined) { + return { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; + } + if (dayValue === '*') { + return { interval: 'daily', scheduleHour, scheduleMinute, scheduleDay: 0 }; + } + const scheduleDay = parseCronValue(dayValue, 0, 6); + return scheduleDay === undefined + ? { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 } + : { interval: 'weekly', scheduleHour, scheduleMinute, scheduleDay }; +} + +function parseCronValue(value: string | undefined, minimum: number, maximum: number): number | undefined { + if (!value || !/^\d+$/.test(value)) { + return undefined; + } + const parsed = Number(value); + return parsed >= minimum && parsed <= maximum ? parsed : undefined; +} + +function scheduleTrigger(schedule: IAutomationSchedule): AutomationDefinition['triggers'] { + if (schedule.interval === 'manual') { + return []; + } + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + let expression: string; + switch (schedule.interval) { + case 'hourly': + expression = `${schedule.scheduleMinute} * * * *`; + break; + case 'daily': + expression = `${schedule.scheduleMinute} ${schedule.scheduleHour} * * *`; + break; + case 'weekly': + expression = `${schedule.scheduleMinute} ${schedule.scheduleHour} * * ${schedule.scheduleDay}`; + break; + } + return [{ + id: 'schedule', + kind: AutomationTriggerKind.Schedule, + schedule: { expression, timeZone }, + misfirePolicy: AutomationMisfirePolicy.RunOnce, + }]; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function setOptional(target: Record, key: string, value: unknown): void { + if (value === undefined) { + delete target[key]; + } else { + target[key] = value; + } +} + +function distinctById(items: readonly T[]): T[] { + const result: T[] = []; + const seen = new Set(); + for (const item of items) { + if (!seen.has(item.id)) { + seen.add(item.id); + result.push(item); + } + } + return result; +} + +function isSerializedArchivedRun(value: unknown): value is ISerializedArchivedRun { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const run = value as Record; + return typeof run['id'] === 'string' + && typeof run['automationId'] === 'string' + && (run['status'] === 'pending' || run['status'] === 'running' || run['status'] === 'completed' || run['status'] === 'failed') + && (run['trigger'] === 'schedule' || run['trigger'] === 'catch_up' || run['trigger'] === 'manual') + && typeof run['startedAt'] === 'string' + && typeof run['leaderWindowId'] === 'number' + && (run['sessionResource'] === undefined || typeof run['sessionResource'] === 'string') + && (run['completedAt'] === undefined || typeof run['completedAt'] === 'string') + && (run['errorMessage'] === undefined || typeof run['errorMessage'] === 'string'); +} + +type ParsedArchivedRuns = + | { readonly kind: 'archive'; readonly runs: readonly IAutomationRun[]; readonly droppedRuns: number } + | { readonly kind: 'invalid'; readonly error: string } + | { readonly kind: 'unsupported'; readonly version: number }; + +function parseArchivedRuns(raw: string): ParsedArchivedRuns { + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + return { kind: 'invalid', error: error instanceof Error ? error.message : String(error) }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { kind: 'invalid', error: 'archive is not an object' }; + } + const archive = value as Record; + if (typeof archive['version'] === 'number' && archive['version'] > LEGACY_RUN_ARCHIVE_VERSION) { + return { kind: 'unsupported', version: archive['version'] }; + } + if (archive['version'] !== LEGACY_RUN_ARCHIVE_VERSION || !Array.isArray(archive['runs'])) { + return { kind: 'invalid', error: 'archive has an invalid version or runs collection' }; + } + const runs: IAutomationRun[] = []; + for (const run of archive['runs']) { + if (!isSerializedArchivedRun(run)) { + continue; + } + try { + runs.push({ + ...run, + sessionResource: run.sessionResource ? URI.parse(run.sessionResource) : undefined, + }); + } catch { + continue; + } + } + return { kind: 'archive', runs, droppedRuns: archive['runs'].length - runs.length }; +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 5dd526de67b72a..ce5ee0627af2e1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -49,6 +49,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; +import { ReconnectableAgentHostAutomationStore } from './reconnectableAgentHostAutomationStore.js'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; @@ -172,7 +173,14 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); - this.automations = this._register(instantiationService.createInstance(AutomationStore, providerAutomationStorageKey(this.id))); + const legacyAutomations = this._register(instantiationService.createInstance(AutomationStore, providerAutomationStorageKey(this.id))); + const automations = this._register(instantiationService.createInstance(ReconnectableAgentHostAutomationStore, this.id, legacyAutomations, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => this.resourceSchemeForProvider(provider), + providerForResourceScheme: scheme => scheme.startsWith(LOCAL_RESOURCE_SCHEME_PREFIX) ? scheme.slice(LOCAL_RESOURCE_SCHEME_PREFIX.length) : undefined, + })); + this.automations = automations; this._isSessionsWindow = environmentService.isSessionsWindow; @@ -196,6 +204,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const connectionListeners = this._register(new DisposableStore()); const bindConnection = () => { connectionListeners.clear(); + automations.setConnection(this._agentHostService); this._attachConnectionListeners(this._agentHostService, connectionListeners); const rootState = this._agentHostService.rootState; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts new file mode 100644 index 00000000000000..1c83ffb14cff86 --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts @@ -0,0 +1,260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, disposableObservableValue, observableSignalFromEvent, observableValue, waitForState, type IObservable } from '../../../../../base/common/observable.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import type { AutomationRunTrigger, IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import type { AutomationMutationGuard, IAutomationRunClaim, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; +import { AgentHostAutomationStore, type IAgentHostAutomationBoundaryMapper, type IAgentHostAutomationConnection } from './agentHostAutomationStore.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; + +const MIGRATION_RETRY_DELAY_MS = 30_000; + +type AutomationAuthorityState = + | { readonly kind: 'disconnected' | 'initializing' | 'unsupported' | 'disabled' } + | { readonly kind: 'supported'; readonly store: AgentHostAutomationStore }; + +export class ReconnectableAgentHostAutomationStore extends Disposable implements ISessionsProviderAutomations { + + readonly preservesImportedRunHistory = true; + + private readonly _currentStore = this._register(disposableObservableValue(this, undefined)); + private readonly _migrationRetry = this._register(new MutableDisposable()); + private readonly _connectionBinding = this._register(new DisposableStore()); + private readonly _runsForCache = new Map>(); + private readonly _configurationChanged; + private readonly _authorityState = observableValue(this, { kind: 'disconnected' }); + private readonly _disposeCancellation = new CancellationTokenSource(); + + readonly automations = derived(this, reader => this._currentStore.read(reader)?.automations.read(reader) ?? this._legacySource?.automations.read(reader) ?? []); + readonly runs = derived(this, reader => this._currentStore.read(reader)?.runs.read(reader) ?? this._legacySource?.runs.read(reader) ?? []); + + constructor( + private readonly _providerId: string, + private readonly _legacySource: ISessionsProviderAutomations | undefined, + private readonly _boundaryMapper: IAgentHostAutomationBoundaryMapper | undefined, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + this._configurationChanged = observableSignalFromEvent(this, this._configurationService.onDidChangeConfiguration); + } + + override dispose(): void { + this._connectionBinding.clear(); + this._migrationRetry.clear(); + this._setAuthorityState({ kind: 'disconnected' }); + this._disposeCancellation.cancel(); + this._disposeCancellation.dispose(); + this._currentStore.set(undefined, undefined); + super.dispose(); + } + + setConnection(connection: IAgentHostAutomationConnection): void { + this._connectionBinding.clear(); + this._migrationRetry.clear(); + this._currentStore.set(undefined, undefined); + this._setAuthorityState({ kind: 'initializing' }); + this._connectionBinding.add(autorun(reader => { + this._configurationChanged.read(reader); + const initializeResult = connection.initializeResult.read(reader); + const enabled = this._configurationService.getValue(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + const current = this._currentStore.read(reader); + if (!enabled) { + if (current) { + this._migrationRetry.clear(); + this._currentStore.set(undefined, undefined); + } + this._setAuthorityState({ kind: 'disabled' }); + return; + } + if (!initializeResult) { + this._setAuthorityState({ kind: 'initializing' }); + return; + } + if (!initializeResult.automations) { + if (current) { + this._migrationRetry.clear(); + this._currentStore.set(undefined, undefined); + } + this._setAuthorityState({ kind: 'unsupported' }); + return; + } + if (!current) { + const store = this._instantiationService.createInstance(AgentHostAutomationStore, this._providerId, connection, this._legacySource, this._boundaryMapper); + this._currentStore.set(store, undefined); + this._setAuthorityState({ kind: 'supported', store }); + this._completeMigration(store); + } else { + this._setAuthorityState({ kind: 'supported', store: current }); + } + })); + } + + clearConnection(): void { + this._connectionBinding.clear(); + this._migrationRetry.clear(); + this._currentStore.set(undefined, undefined); + this._setAuthorityState({ kind: 'disconnected' }); + } + + getAutomation(id: string): IAutomationDescriptor | undefined { + return this._currentStore.get()?.getAutomation(id) ?? this._legacySource?.getAutomation(id); + } + + isSchedulingOwnedByHost(automationId: string): boolean { + return this._currentStore.get()?.isSchedulingOwnedByHost(automationId) === true; + } + + canRunAutomation(automationId: string): boolean { + return this._currentStore.get()?.canRunAutomation(automationId) ?? this._legacySource?.getAutomation(automationId) !== undefined; + } + + canUpdateAutomation(automationId: string): boolean { + return this._currentStore.get()?.canUpdateAutomation(automationId) ?? this._legacySource?.getAutomation(automationId) !== undefined; + } + + canDeleteAutomation(automationId: string): boolean { + return this._currentStore.get()?.canDeleteAutomation(automationId) ?? this._legacySource?.getAutomation(automationId) !== undefined; + } + + runsFor(automationId: string): IObservable { + let result = this._runsForCache.get(automationId); + if (!result) { + result = derived(this, reader => this.runs.read(reader).filter(run => run.automationId === automationId)); + this._runsForCache.set(automationId, result); + } + return result; + } + + createAutomation(options: ICreateAutomationOptions, mutationGuard?: AutomationMutationGuard): Promise { + return this._requireOperationalStore().createAutomation(options, mutationGuard); + } + + updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise { + return this._requireOperationalStore().updateAutomation(id, patch); + } + + updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, expected: IAutomationDescriptor, mutationGuard?: AutomationMutationGuard): Promise { + return this._requireOperationalStore().updateAutomationIfUnchanged(id, patch, expected, mutationGuard); + } + + deleteAutomation(id: string, mutationGuard?: AutomationMutationGuard): Promise { + return this._requireOperationalStore().deleteAutomation(id, mutationGuard); + } + + importAutomationSnapshot(snapshot: IAutomation): Promise { + return this._requireAgentHostStore().importAutomationSnapshot(snapshot); + } + + upsertAutomationSnapshot(snapshot: IAutomation): Promise { + return this._requireAgentHostStore().upsertAutomationSnapshot(snapshot); + } + + removeAutomationSnapshotIfUnchanged(expected: IAutomation): Promise { + return this._requireAgentHostStore().removeAutomationSnapshotIfUnchanged(expected); + } + + acknowledgeAutomationSnapshotImported(snapshot: IAutomation): Promise { + return this._requireAgentHostStore().acknowledgeAutomationSnapshotImported(snapshot); + } + + recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise { + return this._requireOperationalStore().recordRunStart(automationId, trigger, leaderWindowId); + } + + updateRun(runId: string, patch: IUpdateAutomationRunOptions): Promise { + return this._requireOperationalStore().updateRun(runId, patch); + } + + deleteRun(runId: string): Promise { + return this._requireOperationalStore().deleteRun(runId); + } + + getActiveRunFor(automationId: string): IAutomationRun | undefined { + return this._currentStore.get()?.getActiveRunFor(automationId) ?? this._legacySource?.getActiveRunFor(automationId); + } + + async markStaleRunsFailed(reason: string): Promise { + await (this._currentStore.get() ?? this._legacySource)?.markStaleRunsFailed(reason); + } + + async completeMigration(): Promise { + while (true) { + let state = this._authorityState.get(); + if (state.kind === 'initializing') { + const waitCancellation = new CancellationTokenSource(this._disposeCancellation.token); + const waitTimeout = disposableTimeout(() => waitCancellation.cancel(), MIGRATION_RETRY_DELAY_MS); + try { + state = await waitForState(this._authorityState, candidate => candidate.kind !== 'initializing', undefined, waitCancellation.token); + } catch (error) { + if (isCancellationError(error)) { + return; + } + throw error; + } finally { + waitTimeout.dispose(); + waitCancellation.cancel(); + waitCancellation.dispose(); + } + } + if (state.kind !== 'supported') { + return; + } + try { + await state.store.completeMigration(); + return; + } catch (error) { + const current = this._authorityState.get(); + if (current.kind !== 'supported' || current.store !== state.store) { + continue; + } + throw error; + } + } + } + + private _setAuthorityState(state: AutomationAuthorityState): void { + const current = this._authorityState.get(); + if (current.kind === state.kind + && (current.kind !== 'supported' || state.kind !== 'supported' || current.store === state.store)) { + return; + } + this._authorityState.set(state, undefined); + } + + private _completeMigration(store: AgentHostAutomationStore): void { + if (this._store.isDisposed || this._currentStore.get() !== store) { + return; + } + void store.completeMigration().catch(error => { + if (this._store.isDisposed || isCancellationError(error) || this._currentStore.get() !== store) { + return; + } + this._logService.error(`[ReconnectableAgentHostAutomationStore] Failed to initialize remote Automation authority; retrying in ${MIGRATION_RETRY_DELAY_MS}ms.`, error); + this._migrationRetry.value = disposableTimeout(() => this._completeMigration(store), MIGRATION_RETRY_DELAY_MS); + }); + } + + private _requireAgentHostStore(): AgentHostAutomationStore { + const store = this._currentStore.get(); + if (!store) { + throw new Error('The Agent Host does not currently advertise Automation support.'); + } + return store; + } + + private _requireOperationalStore(): ISessionsProviderAutomations { + return this._currentStore.get() ?? this._legacySource ?? this._requireAgentHostStore(); + } +} diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts new file mode 100644 index 00000000000000..38dfdb81565d72 --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -0,0 +1,1518 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore, type IReference } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import type { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; +import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationCatalogState, type AutomationState, type RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AgentHostAutomationStore } from '../../browser/agentHostAutomationStore.js'; +import type { IAutomation } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationStorageService, providerAutomationStorageKey } from '../../../../automations/common/automationStorageService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { TestAutomationStorageService } from '../../../../automations/test/browser/automationTestUtils.js'; +import { AutomationStore } from '../../../../automations/browser/automationService.js'; +import { ReconnectableAgentHostAutomationStore } from '../../browser/reconnectableAgentHostAutomationStore.js'; + +class TestAutomationConnection { + + private readonly _onDidAction = new Emitter(); + readonly onDidAction = this._onDidAction.event; + private readonly _onDidCatalogChange = new Emitter(); + private readonly _onDidRootChange = new Emitter(); + private _catalog: AutomationCatalogState = { automations: [] }; + private _root: RootState; + private _serverSeq = 0; + private _migrationComplete: boolean; + + readonly initializeResult; + readonly rootState: IAgentSubscription; + readonly dispatched: { readonly channel: string; readonly action: Parameters[1] }[] = []; + subscribedChannel: string | undefined; + runPrimarySession = 'mock:/session'; + suppressCreatePublication = false; + updateError: Error | undefined; + readonly createRequested = new DeferredPromise(); + + constructor(migrationComplete: boolean) { + this._migrationComplete = migrationComplete; + this._root = { + agents: [], + activeSessions: 0, + terminals: [], + config: { + schema: { type: 'object', properties: {} }, + values: migrationComplete ? { + [AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY]: { version: 1, status: 'complete', resources: [] }, + } : {}, + }, + }; + const connection = this; + this.rootState = { + get value() { return connection._root; }, + get verifiedValue() { return connection._root; }, + onDidChange: this._onDidRootChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + this.initializeResult = observableValue(this, { + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + automations: migrationComplete ? { create: {}, runCancellation: {} } : { create: {} }, + }); + } + + getSubscriptionByChannel( + kind: StateComponents.AutomationCatalog, + channel: string, + _owner: string, + ): IReference> { + assert.strictEqual(kind, StateComponents.AutomationCatalog); + this.subscribedChannel = channel; + const connection = this; + return { + object: { + get value() { return connection._catalog; }, + get verifiedValue() { return connection._catalog; }, + onDidChange: this._onDidCatalogChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + dispose: () => { }, + }; + } + + dispatch(channel: string, action: Parameters[1]): void { + this.dispatched.push({ channel, action }); + if (action.type === ActionType.AutomationCreateRequested) { + void this.createRequested.complete(); + if (this.suppressCreatePublication) { + return; + } + const timestamp = new Date().toISOString(); + const isPending = !!(action.definition._meta && action.definition._meta[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]); + const operations = isPending + ? [AutomationOperation.Update] + : [AutomationOperation.Update, AutomationOperation.Remove, ...(this._migrationComplete ? [AutomationOperation.Run] : [])]; + const automation = { + resource: action.resource, + definition: action.definition, + runs: [], + operations, + createdAt: timestamp, + modifiedAt: timestamp, + }; + this._catalog = { automations: [...this._catalog.automations, automation] }; + this._onDidCatalogChange.fire(this._catalog); + this._onDidAction.fire({ + channel: AUTOMATION_CATALOG_URI, + action: { type: ActionType.AutomationSet, automation }, + serverSeq: ++this._serverSeq, + origin: undefined, + }); + } else if (action.type === ActionType.AutomationUpdateRequested) { + if (this.updateError) { + throw this.updateError; + } + const current = this._catalog.automations.find(automation => automation.resource === action.resource); + if (!current) { + throw new Error(`Missing Automation: ${action.resource}`); + } + const definition = { ...current.definition, ...action.changes }; + const isPending = !!(definition._meta && definition._meta[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]); + const withoutAuthority = current.operations.filter(op => op !== AutomationOperation.Run && op !== AutomationOperation.Remove); + const operations = isPending + ? withoutAuthority + : [...withoutAuthority, AutomationOperation.Remove, ...(this._migrationComplete ? [AutomationOperation.Run] : [])]; + const automation = { + ...current, + definition, + operations, + modifiedAt: new Date().toISOString(), + }; + this._catalog = { + automations: this._catalog.automations.map(candidate => candidate.resource === automation.resource ? automation : candidate), + }; + this._onDidCatalogChange.fire(this._catalog); + this._onDidAction.fire({ + channel: AUTOMATION_CATALOG_URI, + action: { type: ActionType.AutomationSet, automation }, + serverSeq: ++this._serverSeq, + origin: undefined, + }); + } else if (action.type === ActionType.AutomationRemoved) { + this._catalog = { + ...this._catalog, + automations: this._catalog.automations.filter(automation => automation.resource !== action.resource), + }; + this._onDidCatalogChange.fire(this._catalog); + } else if (action.type === ActionType.RootConfigChanged && action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY]) { + this._migrationComplete = true; + this._catalog = { + ...this._catalog, + automations: this._catalog.automations.map(automation => ({ + ...automation, + operations: automation.definition._meta?.[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY] + ? automation.operations.filter(op => op !== AutomationOperation.Run && op !== AutomationOperation.Remove) + : [...automation.operations.filter(op => op !== AutomationOperation.Run), AutomationOperation.Run], + })), + }; + this._root = { + ...this._root, + config: { + schema: this._root.config?.schema ?? { type: 'object', properties: {} }, + values: { ...this._root.config?.values, ...action.config }, + }, + }; + this._onDidCatalogChange.fire(this._catalog); + this._onDidRootChange.fire(this._root); + } + } + + async listAutomationTriggerDefinitions() { + if (!this._migrationComplete) { + throw new Error('migration pending'); + } + return { items: [] }; + } + + async runAutomation(params: { readonly automation: string }) { + const automation = this._catalog.automations.find(candidate => candidate.resource === params.automation); + if (!automation) { + throw new Error(`Missing Automation: ${params.automation}`); + } + const resource = `ahp-automation-run:/run-${this._serverSeq + 1}`; + const timestamp = new Date().toISOString(); + const updated = { + ...automation, + runs: [{ + resource, + automation: automation.resource, + origin: { kind: AutomationRunOriginKind.Manual as const }, + lifecycle: { status: AutomationRunStatus.Running as const, createdAt: timestamp, startedAt: timestamp }, + primarySession: this.runPrimarySession, + sessionCount: 1, + }, ...automation.runs], + }; + this._catalog = { + ...this._catalog, + automations: this._catalog.automations.map(candidate => candidate.resource === updated.resource ? updated : candidate), + }; + this._onDidCatalogChange.fire(this._catalog); + return { resource }; + } + + setOperations(resource: string, operations: AutomationOperation[]): void { + const current = this._catalog.automations.find(automation => automation.resource === resource); + if (!current) { + throw new Error(`Missing Automation: ${resource}`); + } + const automation = { ...current, operations }; + this._catalog = { + ...this._catalog, + automations: this._catalog.automations.map(candidate => candidate.resource === resource ? automation : candidate), + }; + this._onDidCatalogChange.fire(this._catalog); + } + + setAutomation(automation: AutomationState): void { + this._catalog = { + ...this._catalog, + automations: [ + ...this._catalog.automations.filter(candidate => candidate.resource !== automation.resource), + automation, + ], + }; + this._onDidCatalogChange.fire(this._catalog); + } + + completeRun(resource: string): void { + const timestamp = new Date().toISOString(); + this._catalog = { + ...this._catalog, + automations: this._catalog.automations.map(automation => ({ + ...automation, + runs: automation.runs.map(run => run.resource === resource ? { + ...run, + lifecycle: { + status: AutomationRunStatus.Completed, + createdAt: run.lifecycle.createdAt, + startedAt: run.lifecycle.status === AutomationRunStatus.Running ? run.lifecycle.startedAt : timestamp, + completedAt: timestamp, + }, + } : run), + })), + }; + this._onDidCatalogChange.fire(this._catalog); + } + + dispose(): void { + this._onDidAction.dispose(); + this._onDidCatalogChange.dispose(); + this._onDidRootChange.dispose(); + } +} + +class FailingArchiveStorageService extends TestAutomationStorageService { + override async compareAndSwap(key: string, expectedValue: string | undefined, newValue: string) { + if (key.startsWith('agentHostAutomation.legacyRunArchive.')) { + return { swapped: false, currentValue: expectedValue }; + } + return super.compareAndSwap(key, expectedValue, newValue); + } +} + +class ToggleMigrationAutomationStore extends AutomationStore { + migrationAllowed = false; + + override canCompleteMigration(): boolean { + return this.migrationAllowed; + } +} + +class PausedRemovalAutomationStore extends AutomationStore { + readonly removalStarted = new DeferredPromise(); + readonly resumeRemoval = new DeferredPromise(); + private pauseNextRemoval = true; + + override async removeAutomationSnapshotIfUnchanged(expected: IAutomation) { + if (this.pauseNextRemoval) { + this.pauseNextRemoval = false; + await this.removalStarted.complete(); + await this.resumeRemoval.p; + } + return super.removeAutomationSnapshotIfUnchanged(expected); + } +} + +class RecordingLogService extends NullLogService { + readonly errors: string[] = []; + readonly warnings: string[] = []; + + override error(message: string, ..._args: unknown[]): void { + this.errors.push(message); + } + + override warn(message: string, ..._args: unknown[]): void { + this.warnings.push(message); + } +} + +class RecordingTelemetryService extends NullTelemetryServiceShape { + readonly events: Array<{ readonly name: string; readonly data: Record }> = []; + + override publicLog2(eventName?: string, data?: Record): void { + this.events.push({ name: eventName ?? '', data: data ?? {} }); + } +} + +suite('AgentHostAutomationStore', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function archivedSnapshot(id: string, runId: string): IAutomation { + return { + automation: { + id, + name: id, + prompt: 'Review history.', + schedule: { interval: 'manual', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + enabled: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + runs: [{ + id: runId, + automationId: id, + status: 'completed', + trigger: 'manual', + sessionResource: URI.parse(`mock:/${runId}`), + startedAt: '2026-01-02T00:00:00.000Z', + completedAt: '2026-01-02T00:01:00.000Z', + leaderWindowId: 1, + }], + }; + } + + test('uses the exact catalogue channel and projects authoritative creates', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + connection.runPrimarySession = 'ahp-session:/session'; + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const create = connection.dispatched[0].action; + const trigger = create.type === ActionType.AutomationCreateRequested ? create.definition.triggers[0] : undefined; + + assert.deepStrictEqual({ + subscribedChannel: connection.subscribedChannel, + dispatchChannel: connection.dispatched[0].channel, + definitionMeta: create.type === ActionType.AutomationCreateRequested ? create.definition._meta : undefined, + triggerExpression: trigger?.kind === AutomationTriggerKind.Schedule ? trigger.schedule.expression : undefined, + automation: { + name: automation.name, + prompt: automation.prompt, + schedule: automation.schedule, + target: automation.target, + enabled: automation.enabled, + }, + }, { + subscribedChannel: AUTOMATION_CATALOG_URI, + dispatchChannel: AUTOMATION_CATALOG_URI, + definitionMeta: undefined, + triggerExpression: '30 9 * * *', + automation: { + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + enabled: true, + }, + }); + }); + + test('switches authority only after host migration completion is verified', async () => { + const connection = new TestAutomationConnection(false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await Promise.all([store.completeMigration(), store.completeMigration()]); + await store.completeMigration(); + + const completions = connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI); + const completion = completions[0]; + assert.deepStrictEqual({ + automations: store.automations.get(), + completionCount: completions.length, + completion: completion?.action.type === ActionType.RootConfigChanged + ? completion.action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY] + : undefined, + }, { + automations: [], + completionCount: 1, + completion: { + version: 1, + status: 'complete', + resources: [], + }, + }); + }); + + test('canonicalizes irrelevant schedule fields when updating an interval', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const automation = await store.createAutomation({ + name: 'Scheduled review', + prompt: 'Review changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + + const updated = await store.updateAutomation(automation.id, { + schedule: { interval: 'manual', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, + }); + + assert.deepStrictEqual(updated.schedule, { + interval: 'manual', + scheduleHour: 0, + scheduleMinute: 0, + scheduleDay: 0, + }); + }); + + test('keeps browser scheduling until the specific host definition is migration-ready', async () => { + const connection = new TestAutomationConnection(false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await store.importAutomationSnapshot(archivedSnapshot('scheduled-owner', 'legacy-run')); + + const before = store.isSchedulingOwnedByHost('scheduled-owner'); + await store.completeMigration(); + + assert.deepStrictEqual({ + before, + after: store.isSchedulingOwnedByHost('scheduled-owner'), + }, { + before: false, + after: true, + }); + }); + + test('maps remote workspace and model identifiers at the AHP boundary', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('remote-agent-host', connection, undefined, { + toHost: resource => URI.file(resource.path), + fromHost: resource => URI.from({ scheme: 'client', path: resource.path }), + resourceSchemeForProvider: provider => `remote-test-${provider}`, + providerForSessionScheme: scheme => scheme === 'ahp-session' ? 'mock' : scheme, + }, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + const automation = await store.createAutomation({ + name: 'Remote', + prompt: 'Review.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + modelId: 'remote-test-mock:auto', + target: { + kind: 'workspace', + folderUri: URI.parse('client:/workspace'), + providerId: 'remote-agent-host', + sessionTypeId: 'mock', + isolation: { kind: 'default' }, + }, + }); + const create = connection.dispatched[0].action; + const claim = await store.recordRunStart(automation.id, 'manual', 0); + void claim.externalDispatch?.whenCompleted.catch(() => { }); + + assert.deepStrictEqual({ + hostDirectory: create.type === ActionType.AutomationCreateRequested ? create.definition.session.workingDirectories : undefined, + hostModel: create.type === ActionType.AutomationCreateRequested ? create.definition.session.model?.id : undefined, + clientDirectory: automation.target.kind === 'workspace' ? automation.target.folderUri.toString() : undefined, + clientModel: automation.modelId, + clientSession: claim.run.sessionResource?.toString(), + }, { + hostDirectory: ['file:///workspace'], + hostModel: 'auto', + clientDirectory: 'client:/workspace', + clientModel: 'remote-test-mock:auto', + clientSession: 'remote-test-mock:/session', + }); + }); + + test('maps local Agent Host model identifiers to provider-native ids', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + providerForResourceScheme: scheme => scheme.startsWith('agent-host-') ? scheme.slice('agent-host-'.length) : undefined, + }, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + const automation = await store.createAutomation({ + name: 'Local', + prompt: 'Say hi.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + modelId: 'agent-host-copilotcli:auto', + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + const create = connection.dispatched[0].action; + + assert.deepStrictEqual({ + hostModel: create.type === ActionType.AutomationCreateRequested ? create.definition.session.model?.id : undefined, + clientModel: automation.modelId, + }, { + hostModel: 'auto', + clientModel: 'agent-host-copilotcli:auto', + }); + }); + + test('clears an inherited model when the target authority changes', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + providerForResourceScheme: scheme => scheme.startsWith('agent-host-') ? scheme.slice('agent-host-'.length) : undefined, + }, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const automation = await store.createAutomation({ + name: 'Retargeted', + prompt: 'Say hi.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + modelId: 'agent-host-copilotcli:auto', + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + + const updated = await store.updateAutomation(automation.id, { + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + }); + const update = connection.dispatched.at(-1)?.action; + + assert.deepStrictEqual({ + hostModel: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.model : undefined, + clientModel: updated.modelId, + }, { + hostModel: undefined, + clientModel: undefined, + }); + }); + + test('normalizes a qualified model for the default provider without splitting native colons', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + providerForResourceScheme: scheme => scheme.startsWith('agent-host-') ? scheme.slice('agent-host-'.length) : undefined, + }, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const folderUri = URI.file('/workspace'); + + const defaultProvider = await store.createAutomation({ + name: 'Default provider', + prompt: 'Say hi.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + modelId: 'agent-host-copilotcli:auto', + target: { kind: 'workspace', folderUri, providerId: 'local-agent-host', isolation: { kind: 'default' } }, + }); + const nativeColon = await store.createAutomation({ + name: 'Native colon', + prompt: 'Say hi.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + modelId: 'openai/gpt-5:high', + target: { kind: 'workspace', folderUri, providerId: 'local-agent-host', sessionTypeId: 'copilotcli', isolation: { kind: 'default' } }, + }); + const createActions = connection.dispatched + .map(entry => entry.action) + .filter(action => action.type === ActionType.AutomationCreateRequested); + + assert.deepStrictEqual({ + hostProviders: createActions.map(action => action.definition.session.provider), + hostModels: createActions.map(action => action.definition.session.model?.id), + clientModels: [defaultProvider.modelId, nativeColon.modelId], + }, { + hostProviders: ['copilotcli', 'copilotcli'], + hostModels: ['auto', 'openai/gpt-5:high'], + clientModels: ['agent-host-copilotcli:auto', 'agent-host-copilotcli:openai/gpt-5:high'], + }); + }); + + test('qualifies host-authored models without retargeting historical run sessions', () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + }, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const timestamp = new Date().toISOString(); + + connection.setAutomation({ + resource: 'ahp-automation:/host-authored', + definition: { + title: 'Host-authored', + message: { text: 'Say hi.', origin: { kind: MessageKind.Automation } }, + session: { provider: 'codex', model: { id: 'auto' } }, + enabled: true, + triggers: [], + }, + runs: [{ + resource: 'ahp-automation-run:/host-authored-run', + automation: 'ahp-automation:/host-authored', + origin: { kind: AutomationRunOriginKind.Manual }, + lifecycle: { + status: AutomationRunStatus.Completed, + createdAt: timestamp, + startedAt: timestamp, + completedAt: timestamp, + }, + primarySession: 'copilotcli:/host-authored-session', + sessionCount: 1, + }], + operations: [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run], + createdAt: timestamp, + modifiedAt: timestamp, + }); + + assert.deepStrictEqual({ + modelId: store.getAutomation('host-authored')?.modelId, + sessionResource: store.runs.get()[0].sessionResource?.toString(), + }, { + modelId: 'agent-host-codex:auto', + sessionResource: 'agent-host-copilotcli:/host-authored-session', + }); + }); + + test('uses per-automation operations as the client authority', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const automation = await store.createAutomation({ + name: 'Restricted', + prompt: 'Review.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + connection.setOperations(`ahp-automation:/${automation.id}`, [AutomationOperation.Update]); + + await assert.rejects(store.deleteAutomation(automation.id), /operation 'remove' is not available/); + + assert.deepStrictEqual({ + canRun: store.canRunAutomation(automation.id), + canUpdate: store.canUpdateAutomation(automation.id), + canDelete: store.canDeleteAutomation(automation.id), + }, { + canRun: false, + canUpdate: true, + canDelete: false, + }); + }); + + test('dispatches run cancellation only when the capability is advertised', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const automation = await store.createAutomation({ + name: 'Cancelable', + prompt: 'Review.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + + const claim = await store.recordRunStart(automation.id, 'manual', 0); + claim.externalDispatch?.cancel?.(); + void claim.externalDispatch?.whenCompleted.catch(() => { }); + + const cancellation = connection.dispatched.at(-1); + assert.deepStrictEqual(cancellation, { + channel: `ahp-automation-run:/${claim.run.id}`, + action: { type: ActionType.AutomationRunCancelRequested }, + }); + }); + + test('does not time out an authority-dispatched run after 30 seconds', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + }, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const automation = await store.createAutomation({ + name: 'Long-running', + prompt: 'Review.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const claim = await store.recordRunStart(automation.id, 'manual', 0); + let settled = false; + void claim.externalDispatch!.whenCompleted.finally(() => settled = true); + + await timeout(31_000); + assert.strictEqual(settled, false); + + connection.completeRun(`ahp-automation-run:/${claim.run.id}`); + await claim.externalDispatch!.whenCompleted; + assert.strictEqual(settled, true); + })); + + test('retains imported legacy run history in a read-only archive across store recreation', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const snapshot = archivedSnapshot('archived', 'legacy-run'); + const first = new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage); + await first.importAutomationSnapshot(snapshot); + first.dispose(); + + const restored = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + assert.deepStrictEqual( + restored.runs.get().map(run => ({ ...run, sessionResource: run.sessionResource?.toString() })), + snapshot.runs.map(run => ({ ...run, sessionResource: run.sessionResource?.toString() })), + ); + }); + + test('repairs malformed legacy run archive rows while preserving valid history', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const logService = new RecordingLogService(); + const archiveKey = 'agentHostAutomation.legacyRunArchive.local-agent-host'; + const existingRun = archivedSnapshot('existing', 'run-existing').runs[0]; + await automationStorage.compareAndSwap(archiveKey, undefined, JSON.stringify({ + version: 1, + runs: [ + { ...existingRun, sessionResource: existingRun.sessionResource?.toString() }, + { id: 'malformed' }, + ], + })); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, logService, storage, NullTelemetryService, automationStorage)); + + await store.importAutomationSnapshot(archivedSnapshot('imported', 'run-imported')); + + const persisted = JSON.parse((await automationStorage.read(archiveKey))!); + assert.deepStrictEqual({ + runIds: persisted.runs.map((run: { id: string }) => run.id).sort(), + loggedRepair: logService.warnings.some(message => message.includes('malformed run(s) while repairing legacy run archive')), + }, { + runIds: ['run-existing', 'run-imported'], + loggedRepair: true, + }); + }); + + test('replaces an unreadable legacy run archive while importing history', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const logService = new RecordingLogService(); + const archiveKey = 'agentHostAutomation.legacyRunArchive.local-agent-host'; + await automationStorage.compareAndSwap(archiveKey, undefined, '{'); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, logService, storage, NullTelemetryService, automationStorage)); + + await store.importAutomationSnapshot(archivedSnapshot('imported', 'run-imported')); + + const persisted = JSON.parse((await automationStorage.read(archiveKey))!); + assert.deepStrictEqual({ + runIds: persisted.runs.map((run: { id: string }) => run.id), + loggedReplacement: logService.errors.some(message => message.includes('Replacing invalid legacy run archive')), + }, { + runIds: ['run-imported'], + loggedReplacement: true, + }); + }); + + test('merges concurrent legacy run archives without lost updates', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const first = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const second = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await Promise.all([ + first.importAutomationSnapshot(archivedSnapshot('first', 'run-first')), + second.importAutomationSnapshot(archivedSnapshot('second', 'run-second')), + ]); + + const restored = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + assert.deepStrictEqual(restored.runs.get().map(run => run.id).sort(), ['run-first', 'run-second']); + }); + + test('an interrupted legacy import retries by updating its prior host entry', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const initial = archivedSnapshot('retry', 'run-initial'); + await store.importAutomationSnapshot(initial); + const changed: IAutomation = { + automation: { ...initial.automation, name: 'Changed during migration' }, + runs: initial.runs, + }; + + const result = await store.importAutomationSnapshot(changed); + + assert.deepStrictEqual({ + result, + name: store.getAutomation('retry')?.name, + }, { + result: { kind: 'alreadyPresent' }, + name: 'Changed during migration', + }); + }); + + test('publicly importing an unacknowledged snapshot stages pending and withholds host Run', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await store.importAutomationSnapshot(archivedSnapshot('pending', 'run-pending')); + + const createAction = connection.dispatched.find(entry => entry.action.type === ActionType.AutomationCreateRequested)?.action; + const pendingMeta = createAction?.type === ActionType.AutomationCreateRequested + ? createAction.definition._meta?.[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY] + : undefined; + assert.deepStrictEqual({ + pendingMeta, + canRun: store.canRunAutomation('pending'), + }, { + pendingMeta: true, + canRun: false, + }); + }); + + test('re-importing the same unacknowledged snapshot keeps the pending flag applied', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const snapshot = archivedSnapshot('pending-retry', 'run-retry'); + await store.importAutomationSnapshot(snapshot); + + await store.importAutomationSnapshot(snapshot); + + assert.deepStrictEqual({ + canRun: store.canRunAutomation('pending-retry'), + }, { + canRun: false, + }); + }); + + test('a durable legacy removal clears the pending flag and restores Run authority', async () => { + const connection = new TestAutomationConnection(false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + await legacy.createAutomation({ + name: 'Scheduled review', + prompt: 'Review changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await store.completeMigration(); + + const migratedId = store.automations.get()[0]?.id ?? ''; + assert.deepStrictEqual({ + legacyAutomations: legacy.automations.get(), + canRun: store.canRunAutomation(migratedId), + isHostOwned: store.isSchedulingOwnedByHost(migratedId), + }, { + legacyAutomations: [], + canRun: true, + isHostOwned: true, + }); + }); + + test('a stranded pending row is drained when the legacy source no longer holds it', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + // No legacy source: models a cross-provider transfer that removed the + // row before the AHP import could be acknowledged. + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await store.importAutomationSnapshot(archivedSnapshot('stranded', 'run-stranded')); + assert.strictEqual(store.canRunAutomation('stranded'), false); + + await store.completeMigration(); + + assert.deepStrictEqual({ + canRun: store.canRunAutomation('stranded'), + isHostOwned: store.isSchedulingOwnedByHost('stranded'), + }, { + canRun: true, + isHostOwned: true, + }); + }); + + test('a failed pending-import drain keeps migration unready and retryable', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const logService = new RecordingLogService(); + const telemetryService = new RecordingTelemetryService(); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, logService, storage, telemetryService, automationStorage)); + await store.importAutomationSnapshot(archivedSnapshot('stranded', 'run-stranded')); + connection.updateError = new Error('update unavailable'); + + await assert.rejects(store.completeMigration(), /Failed to drain 1 pending Agent Host Automation import/); + + const failedEvent = telemetryService.events.find(event => event.name === 'automation.migration' && event.data['outcome'] === 'failed'); + assert.deepStrictEqual({ + canRun: store.canRunAutomation('stranded'), + isHostOwned: store.isSchedulingOwnedByHost('stranded'), + failedCount: failedEvent?.data['failedCount'], + }, { + canRun: false, + isHostOwned: false, + failedCount: 1, + }); + + connection.updateError = undefined; + await store.completeMigration(); + + assert.deepStrictEqual({ + canRun: store.canRunAutomation('stranded'), + isHostOwned: store.isSchedulingOwnedByHost('stranded'), + }, { + canRun: true, + isHostOwned: true, + }); + }); + + test('acknowledgeAutomationSnapshotImported clears pending and restores Run authority', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const snapshot = archivedSnapshot('retargeted', 'run-retargeted'); + await store.upsertAutomationSnapshot(snapshot); + assert.strictEqual(store.canRunAutomation('retargeted'), false); + + await store.acknowledgeAutomationSnapshotImported(snapshot); + + assert.deepStrictEqual({ + canRun: store.canRunAutomation('retargeted'), + isHostOwned: store.isSchedulingOwnedByHost('retargeted'), + }, { + canRun: true, + isHostOwned: true, + }); + }); + + test('acknowledgeAutomationSnapshotImported is a no-op when the row is not pending', async () => { + const connection = new TestAutomationConnection(false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await store.acknowledgeAutomationSnapshotImported(archivedSnapshot('absent', 'run-absent')); + + assert.strictEqual(store.canRunAutomation('absent'), false); + }); + + test('a failed durable legacy removal keeps the pending flag set until the next drain succeeds', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new PausedRemovalAutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const initial = await legacy.createAutomation({ + name: 'Retry me', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const migration = store.completeMigration(); + await legacy.removalStarted.p; + + // Meanwhile the legacy row mutates so the paused CAS remove will fail + // with a conflict once resumed. Migration retries and succeeds on the + // snapshot's updated value; the pending flag stays set through the + // failed attempt and only clears once removal actually goes through. + await legacy.updateAutomation(initial.id, { name: 'Mutated during migration' }); + await legacy.resumeRemoval.complete(); + await migration; + + const migratedId = store.automations.get()[0]?.id ?? ''; + assert.deepStrictEqual({ + legacyAutomations: legacy.automations.get(), + canRun: store.canRunAutomation(migratedId), + }, { + legacyAutomations: [], + canRun: true, + }); + }); + + test('migrates a real legacy ledger and archives its run before source removal', async () => { + const connection = new TestAutomationConnection(false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const automation = await legacy.createAutomation({ + name: 'Scheduled review', + prompt: 'Review changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const claim = await legacy.recordRunStart(automation.id, 'manual', 1); + await legacy.updateRun(claim.run.id, { status: 'completed', completedAt: '2026-01-01T00:01:00.000Z' }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await store.completeMigration(); + const migrationUpdate = [...connection.dispatched].reverse() + .map(entry => entry.action) + .find(action => action.type === ActionType.AutomationUpdateRequested); + const trigger = migrationUpdate?.type === ActionType.AutomationUpdateRequested ? migrationUpdate.changes.triggers?.[0] : undefined; + + assert.deepStrictEqual({ + legacyAutomations: legacy.automations.get(), + migratedNames: store.automations.get().map(candidate => candidate.name), + archivedRunIds: store.runs.get().map(run => run.id), + definitionMeta: migrationUpdate?.type === ActionType.AutomationUpdateRequested ? migrationUpdate.changes._meta : undefined, + triggerExpression: trigger?.kind === AutomationTriggerKind.Schedule ? trigger.schedule.expression : undefined, + }, { + legacyAutomations: [], + migratedNames: ['Scheduled review'], + archivedRunIds: [claim.run.id], + definitionMeta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY]: true }, + triggerExpression: '30 9 * * *', + }); + }); + + test('waits for migration before creating directly in the host catalogue', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new PausedRemovalAutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const initial = await legacy.createAutomation({ + name: 'Initial', + prompt: 'Review initial changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const migration = store.completeMigration(); + await legacy.removalStarted.p; + + let createSettled = false; + const create = store.createAutomation({ + name: 'Created during migration', + prompt: 'Review later changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }).finally(() => createSettled = true); + await Promise.resolve(); + const before = { + createSettled, + legacyNames: legacy.automations.get().map(automation => automation.name), + hostCreateRequests: connection.dispatched.filter(entry => entry.action.type === ActionType.AutomationCreateRequested).length, + }; + + await legacy.resumeRemoval.complete(); + const created = await create; + await migration; + const completion = connection.dispatched.find(entry => entry.action.type === ActionType.RootConfigChanged)?.action; + + assert.deepStrictEqual({ + before, + createdName: created.name, + legacyAutomations: legacy.automations.get(), + hostNames: store.automations.get().map(automation => automation.name).sort(), + completion: completion?.type === ActionType.RootConfigChanged + ? completion.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY] + : undefined, + }, { + before: { + createSettled: false, + legacyNames: ['Initial'], + hostCreateRequests: 1, + }, + createdName: 'Created during migration', + legacyAutomations: [], + hostNames: ['Created during migration', 'Initial'], + completion: { + version: 1, + status: 'complete', + resources: [`ahp-automation:/${initial.id}`], + }, + }); + }); + + test('waits for migration before deleting from the host catalogue', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new PausedRemovalAutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const automation = await legacy.createAutomation({ + name: 'Delete during migration', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const migration = store.completeMigration(); + await legacy.removalStarted.p; + + let deleteSettled = false; + const deletion = store.deleteAutomation(automation.id).finally(() => deleteSettled = true); + await Promise.resolve(); + const before = { + deleteSettled, + legacyIds: legacy.automations.get().map(candidate => candidate.id), + hostRemoveRequests: connection.dispatched.filter(entry => entry.action.type === ActionType.AutomationRemoved).length, + }; + + await legacy.resumeRemoval.complete(); + await deletion; + await migration; + + assert.deepStrictEqual({ + before, + legacyAutomations: legacy.automations.get(), + hostAutomations: store.automations.get(), + hostRemoveRequests: connection.dispatched.filter(entry => entry.action.type === ActionType.AutomationRemoved).length, + }, { + before: { + deleteSettled: false, + legacyIds: [automation.id], + hostRemoveRequests: 0, + }, + legacyAutomations: [], + hostAutomations: [], + hostRemoveRequests: 1, + }); + }); + + test('retries migration instead of hiding a legacy definition added during transfer', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new PausedRemovalAutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + await legacy.createAutomation({ + name: 'Initial', + prompt: 'Review initial changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const migration = store.completeMigration(); + await legacy.removalStarted.p; + const added = await legacy.createAutomation({ + name: 'Added by another window', + prompt: 'Review concurrent changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + + await legacy.resumeRemoval.complete(); + await assert.rejects(migration, /source changed during migration; 1 definition\(s\) remain/); + const beforeRetry = { + legacyIds: legacy.automations.get().map(automation => automation.id), + visibleNames: store.automations.get().map(automation => automation.name).sort(), + completionRequests: connection.dispatched.filter(entry => entry.action.type === ActionType.RootConfigChanged).length, + }; + + await store.completeMigration(); + + assert.deepStrictEqual({ + beforeRetry, + legacyAutomations: legacy.automations.get(), + hostNames: store.automations.get().map(automation => automation.name).sort(), + }, { + beforeRetry: { + legacyIds: [added.id], + visibleNames: ['Added by another window', 'Initial'], + completionRequests: 0, + }, + legacyAutomations: [], + hostNames: ['Added by another window', 'Initial'], + }); + }); + + test('drains residual legacy definitions before accepting an already-migrated host', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const automation = await legacy.createAutomation({ + name: 'Residual', + prompt: 'Review residual changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + const before = { + schedulingOwnedByHost: store.isSchedulingOwnedByHost(automation.id), + legacyIds: legacy.automations.get().map(candidate => candidate.id), + }; + await store.completeMigration(); + + assert.deepStrictEqual({ + before, + schedulingOwnedByHost: store.isSchedulingOwnedByHost(automation.id), + legacyAutomations: legacy.automations.get(), + hostNames: store.automations.get().map(candidate => candidate.name), + }, { + before: { + schedulingOwnedByHost: false, + legacyIds: [automation.id], + }, + schedulingOwnedByHost: true, + legacyAutomations: [], + hostNames: ['Residual'], + }); + }); + + test('archive persistence failure leaves the legacy source intact and migration gated', async () => { + const connection = new TestAutomationConnection(false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new FailingArchiveStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const automation = await legacy.createAutomation({ + name: 'Preserve me', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const claim = await legacy.recordRunStart(automation.id, 'manual', 1); + await legacy.updateRun(claim.run.id, { status: 'completed', completedAt: '2026-01-01T00:01:00.000Z' }); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + + await assert.rejects(store.completeMigration(), /Failed to migrate 1 Agent Host Automation definition/); + + assert.deepStrictEqual({ + legacyIds: legacy.automations.get().map(candidate => candidate.id), + completionRequests: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, { + legacyIds: [automation.id], + completionRequests: 0, + }); + }); + + test('rebind after a failed migration creates a fresh authority and retries immediately', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new ToggleMigrationAutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const instantiationService = disposables.add(new TestInstantiationService()); + const configurationService = new TestConfigurationService({ chat: { automations: { enabled: true } } }); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + legacy, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + await assert.rejects(store.completeMigration(), /cannot be migrated safely/); + + legacy.migrationAllowed = true; + store.setConnection(connection); + await store.completeMigration(); + + assert.deepStrictEqual({ + subscriptions: connection.subscribedChannel, + completionRequests: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, { + subscriptions: AUTOMATION_CATALOG_URI, + completionRequests: 1, + }); + }); + + test('migration remains retryable while connection capabilities are initializing', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + connection.initializeResult.set(undefined, undefined); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + const instantiationService = disposables.add(new TestInstantiationService()); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + undefined, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + + let settled = false; + const migration = store.completeMigration().finally(() => settled = true); + await Promise.resolve(); + assert.strictEqual(settled, false); + connection.initializeResult.set({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + automations: { create: {} }, + }, undefined); + await migration; + + assert.strictEqual(connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, 1); + }); + + test('migration resolves without subscribing after an older host finishes initializing', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + connection.initializeResult.set(undefined, undefined); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + const instantiationService = disposables.add(new TestInstantiationService()); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + undefined, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + const migration = store.completeMigration(); + + connection.initializeResult.set({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + }, undefined); + await migration; + + assert.deepStrictEqual({ + subscribedChannel: connection.subscribedChannel, + completionRequests: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, { + subscribedChannel: undefined, + completionRequests: 0, + }); + }); + + test('stalled capability initialization cannot block migration indefinitely', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + connection.initializeResult.set(undefined, undefined); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + const instantiationService = disposables.add(new TestInstantiationService()); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + undefined, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + + await store.completeMigration(); + + assert.strictEqual(connection.dispatched.length, 0); + })); + + test('disposing while capabilities initialize settles migration', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + connection.initializeResult.set(undefined, undefined); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + const instantiationService = disposables.add(new TestInstantiationService()); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + undefined, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + const migration = store.completeMigration(); + + store.dispose(); + + await migration; + }); + + test('disposing a supported authority during migration does not schedule zombie retries', async () => { + const connection = disposables.add(new TestAutomationConnection(false)); + connection.suppressCreatePublication = true; + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + const instantiationService = disposables.add(new TestInstantiationService()); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const logService = new RecordingLogService(); + const telemetryService = new RecordingTelemetryService(); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + await legacy.createAutomation({ + name: 'Pending migration', + prompt: 'Review.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, logService); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, telemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + legacy, + undefined, + instantiationService, + logService, + configurationService, + )); + store.setConnection(connection); + const migration = store.completeMigration(); + await connection.createRequested.p; + + store.dispose(); + await migration; + + assert.deepStrictEqual({ + createRequests: connection.dispatched.filter(entry => entry.action.type === ActionType.AutomationCreateRequested).length, + migrationErrors: logService.errors.filter(message => message.includes('Automation migration failed')), + failedTelemetry: telemetryService.events.filter(event => event.name === 'automation.migration' && event.data['outcome'] === 'failed'), + }, { + createRequests: 1, + migrationErrors: [], + failedTelemetry: [], + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 418c6b3a520db8..0b1ea60840c191 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -20,8 +20,8 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSes import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationCatalogState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -65,7 +65,7 @@ import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessi const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; -type SubscriptionState = SessionState | ChangesetState | ChatState; +type SubscriptionState = SessionState | ChangesetState | ChatState | AutomationCatalogState; class MockAgentHostService extends mock() { declare readonly _serviceBrand: undefined; @@ -85,6 +85,11 @@ class MockAgentHostService extends mock() { override get rootState(): IAgentSubscription { return this._rootStateSubscription; } private readonly _onAgentHostStart = new Emitter(); override readonly onAgentHostStart = this._onAgentHostStart.event; + override readonly initializeResult = constObservable({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + }); override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); @@ -251,6 +256,17 @@ class MockAgentHostService extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); + return this._getSubscription(key); + } + + override getSubscriptionByChannel(_kind: StateComponents, channel: string): IReference> { + if (channel === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(channel)) { + this._sessionStateValues.set(channel, { automations: [] }); + } + return this._getSubscription(channel); + } + + private _getSubscription(key: string): IReference> { this.wireOps.push(`subscribe:${key}`); this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1); let emitter = this._sessionStateEmitters.get(key); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 71a2431c4b4bdc..e8a319af143c05 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -40,7 +40,11 @@ import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspa import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; -import { remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { ReconnectableAgentHostAutomationStore } from '../../agentHost/browser/reconnectableAgentHostAutomationStore.js'; +import type { ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; +import { AutomationStore } from '../../../automations/browser/automationService.js'; +import { providerAutomationStorageKey } from '../../../automations/common/automationStorageService.js'; +import { remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; /** Storage key prefix for cached session summaries, per remote address. */ const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.v2.'; @@ -126,6 +130,8 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly canConnectOnDemand: boolean; readonly onDidReportConnectProgress: Event | undefined; + readonly automations: ISessionsProviderAutomations; + private readonly _automationStore: ReconnectableAgentHostAutomationStore; private readonly _connectionStatus = observableValue('connectionStatus', RemoteAgentHostConnectionStatus.disconnected); /** @@ -215,6 +221,18 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this.remoteAddress = config.address; this.remoteLocationPreferenceKey = config.preferenceKey ?? config.address; this._storageKey = `${CACHED_SESSIONS_STORAGE_PREFIX}${this._connectionAuthority}`; + const legacyAutomations = this._register(instantiationService.createInstance(AutomationStore, providerAutomationStorageKey(this.id))); + this._automationStore = this._register(instantiationService.createInstance(ReconnectableAgentHostAutomationStore, this.id, legacyAutomations, { + toHost: resource => fromAgentHostUri(resource), + fromHost: resource => toAgentHostUri(resource, this._connectionAuthority), + resourceSchemeForProvider: provider => this.resourceSchemeForProvider(provider), + providerForSessionScheme: scheme => this._sessionSchemeAlias?.backend === scheme ? this._sessionSchemeAlias.ui : scheme, + providerForResourceScheme: scheme => { + const prefix = remoteAgentHostSessionTypeAuthorityPrefix(this._connectionAuthority); + return scheme.startsWith(prefix) ? scheme.slice(prefix.length) : undefined; + }, + })); + this.automations = this._automationStore; this.browseActions = [{ label: localize('folders', "Folders"), @@ -434,6 +452,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._connectionListeners.clear(); this._sessionStateSubscriptions.clearAndDisposeAll(); this._connection = connection; + this._automationStore.setConnection(connection); this._defaultDirectory = defaultDirectory; this._unpublished = false; @@ -467,6 +486,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._sessionStateSubscriptions.clearAndDisposeAll(); this._onDidDisconnect.fire(); this._connection = undefined; + this._automationStore.clearConnection(); this._defaultDirectory = undefined; this._disposeAllNewSessions(); this._syncRootState(undefined); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 241c98cd4ff342..4fda084efc622a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -17,9 +17,9 @@ import { AgentSession, type IAgentSessionMetadata } from '../../../../../../plat import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { MessageKind, SessionLifecycle, type AgentInfo, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { MessageKind, SessionLifecycle, type AgentInfo, type AutomationCatalogState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { buildDefaultChatUri, SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, buildDefaultChatUri, SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -58,6 +58,12 @@ class MockAgentConnection extends mock() { private readonly _onDidRootStateChange = new Emitter(); private _rootStateValue: RootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] }; override readonly rootState: IAgentSubscription; + override readonly initializeResult = constObservable({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + automations: { create: {}, schedules: {}, runCancellation: {} }, + }); override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); @@ -124,9 +130,9 @@ class MockAgentConnection extends mock() { // ---- Session-state subscriptions --------------------------------------- - private readonly _sessionStateEmitters = new Map>(); + private readonly _sessionStateEmitters = new Map>(); private readonly _sessionStateErrorEmitters = new Map>(); - private readonly _sessionStateValues = new Map(); + private readonly _sessionStateValues = new Map(); public sessionSubscribeCounts = new Map(); public sessionUnsubscribeCounts = new Map(); /** @@ -137,10 +143,21 @@ class MockAgentConnection extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); + return this._getSubscription(key); + } + + override getSubscriptionByChannel(_kind: StateComponents, channel: string): IReference> { + if (channel === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(channel)) { + this._sessionStateValues.set(channel, { automations: [] }); + } + return this._getSubscription(channel); + } + + private _getSubscription(key: string): IReference> { this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1); let emitter = this._sessionStateEmitters.get(key); if (!emitter) { - emitter = new Emitter(); + emitter = new Emitter(); this._sessionStateEmitters.set(key, emitter); } let errorEmitter = this._sessionStateErrorEmitters.get(key); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index 124113016ae379..5a9ca4eb575626 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -58,8 +58,10 @@ const DELETE_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.deleteRun interface IAutomationCardEntry { readonly element: HTMLElement; readonly card: HTMLElement; - readonly main: HTMLElement; + readonly main: HTMLButtonElement; readonly actions: HTMLElement; + readonly runButton: IButton; + readonly deleteButton: IButton; readonly nameText: HTMLElement; readonly scheduleEl: HTMLElement; readonly folderEl: HTMLElement; @@ -258,7 +260,7 @@ class AutomationCardsSection extends Disposable { card.setAttribute('role', 'group'); disposables.add(Gesture.addTarget(card)); - const main = DOM.append(card, $('button.automations-card-main', { + const main = DOM.append(card, $('button.automations-card-main', { type: 'button', })); @@ -279,29 +281,29 @@ class AutomationCardsSection extends Disposable { const buttonBar = disposables.add(new ButtonBar(actions)); const runNowLabel = localize('runNow', "Run now"); const runningLabel = localize('running', "Running"); - const runBtn = this.createIconButton(buttonBar, Codicon.play, runNowLabel, false); + const runBtn = this.createIconButton(buttonBar, Codicon.play, runNowLabel, this.automationService.canRunAutomation?.(automation.id) === false); runBtn.element.classList.add('automations-card-run-button'); disposables.add(runBtn.onDidClick((e) => { e?.stopPropagation(); const currentAutomation = this.latestAutomations.get(automation.id); - if (!currentAutomation) { + if (!currentAutomation || this.automationService.canRunAutomation?.(automation.id) === false) { return; } runBtn.enabled = false; runBtn.setAriaLabel(runningLabel); runBtn.setTitle(runningLabel); disposableTimeout(() => { - runBtn.enabled = true; + runBtn.enabled = this.automationService.canRunAutomation?.(automation.id) !== false; runBtn.setAriaLabel(runNowLabel); runBtn.setTitle(runNowLabel); }, 10_000, disposables); void this.runNow(currentAutomation); })); - const deleteBtn = this.createIconButton(buttonBar, Codicon.trash, localize('deleteAutomation', "Delete"), false); + const deleteBtn = this.createIconButton(buttonBar, Codicon.trash, localize('deleteAutomation', "Delete"), this.automationService.canDeleteAutomation?.(automation.id) === false); disposables.add(deleteBtn.onDidClick(() => { const currentAutomation = this.latestAutomations.get(automation.id); - if (!currentAutomation) { + if (!currentAutomation || this.automationService.canDeleteAutomation?.(automation.id) === false) { return; } void this.confirmDelete(currentAutomation); @@ -314,7 +316,7 @@ class AutomationCardsSection extends Disposable { return; } const currentAutomation = this.latestAutomations.get(automation.id); - if (!currentAutomation) { + if (!currentAutomation || this.automationService.canUpdateAutomation?.(automation.id) === false) { return; } void this.openEditDialog(currentAutomation); @@ -326,6 +328,8 @@ class AutomationCardsSection extends Disposable { card, main, actions, + runButton: runBtn, + deleteButton: deleteBtn, nameText: nameTextEl, scheduleEl, folderEl, @@ -339,6 +343,9 @@ class AutomationCardsSection extends Disposable { } private updateCard(card: IAutomationCardEntry, automation: IAutomationDescriptor, previous?: IAutomationDescriptor): void { + card.main.disabled = this.automationService.canUpdateAutomation?.(automation.id) === false; + card.runButton.enabled = this.automationService.canRunAutomation?.(automation.id) !== false; + card.deleteButton.enabled = this.automationService.canDeleteAutomation?.(automation.id) !== false; const schedule = formatSchedule(automation); const scheduleChanged = !previous || formatSchedule(previous) !== schedule; const nameChanged = !previous || previous.name !== automation.name; diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 2b90a8347d5532..1c5344753af5a0 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -110,12 +110,29 @@ export type IGuardedAutomationSnapshotRemovalResult = | { readonly kind: 'missing' }; export interface ISessionsProviderAutomations extends IAutomationStore { + canRunAutomation?(automationId: string): boolean; + canUpdateAutomation?(automationId: string): boolean; + canDeleteAutomation?(automationId: string): boolean; + /** Whether this provider's authority currently evaluates the given Automation's schedule. */ + isSchedulingOwnedByHost?(automationId: string): boolean; + /** Whether importing a snapshot preserves its historical runs. Defaults to true. */ + readonly preservesImportedRunHistory?: boolean; + /** Whether every persisted source row was understood and can be migrated without loss. */ + canCompleteMigration?(): boolean; /** Imports a snapshot without replacing an Automation already stored under the same ID. */ importAutomationSnapshot(snapshot: IAutomation): Promise; /** Inserts or replaces an Automation snapshot without publishing create or update telemetry. */ upsertAutomationSnapshot(snapshot: IAutomation): Promise; /** Removes a snapshot only when the currently stored Automation and runs still match it. */ removeAutomationSnapshotIfUnchanged(expected: IAutomation): Promise; + /** + * Signals that an imported snapshot's source row has been durably removed and the destination + * store may release any staging holds (e.g. the pending-import flag that suppresses scheduling + * authority until the source is gone). + */ + acknowledgeAutomationSnapshotImported?(snapshot: IAutomation): Promise; + /** Finalizes any authority migration after all snapshots have been imported and verified. */ + completeMigration?(): Promise; } /** diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index aacc965efd2d71..0a7b38c38abb8a 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -105,6 +105,12 @@ export interface IAutomationRunClaim { readonly claimed: boolean; /** The run occupying the slot: the newly recorded one, or the pre-existing one. */ readonly run: IAutomationRun; + /** Present when the backing authority already dispatched execution for this claim. */ + readonly externalDispatch?: { + readonly sessionResource?: URI; + readonly whenCompleted: Promise; + cancel?(): void; + }; } /** @@ -160,6 +166,11 @@ export interface IAutomationStore { export interface IAutomationService extends IAutomationStore { readonly _serviceBrand: undefined; + canRunAutomation?(automationId: string): boolean; + canUpdateAutomation?(automationId: string): boolean; + canDeleteAutomation?(automationId: string): boolean; + /** Whether the target authority, rather than this window's scheduler, evaluates this Automation. */ + isSchedulingOwnedByHost?(automationId: string): boolean; /** Starts leader-scoped stale-run recovery and includes provider stores added while active. */ startStaleRunRecovery(reason: string): Promise; /** Stops leader-scoped stale-run recovery. */ diff --git a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts index 1e56b0be756bc2..d9898474b71d1f 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts @@ -151,3 +151,25 @@ export function publishAutomationRunError(telemetryService: ITelemetryService, a intervalKind: args.automation.schedule.interval, }); } + +type AutomationMigrationEvent = { + outcome: 'started' | 'completed' | 'failed'; + discoveredCount: number; + migratedCount: number; + failedCount: number; + durationMs: number; +}; + +type AutomationMigrationClassification = { + outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the migration started, completed, or failed.' }; + discoveredCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of legacy Automation definitions discovered.' }; + migratedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of Automation definitions durably present in the Agent Host catalogue.' }; + failedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of Automation definitions that failed migration.' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Migration duration in milliseconds, or zero for the started event.' }; + owner: 'ulugbekna'; + comment: 'Tracks reliability of the one-time migration to Agent Host-owned Automations without collecting definition content or resource identifiers.'; +}; + +export function publishAutomationMigration(telemetryService: ITelemetryService, event: AutomationMigrationEvent): void { + telemetryService.publicLog2('automation.migration', event); +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts b/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts index ae8d8894a5297e..31efd8bd3cc423 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts @@ -11,7 +11,7 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/context */ export const CHAT_AUTOMATIONS_ENABLED_SETTING = 'chat.automations.enabled'; -/** Per-run timeout in minutes. Hung runs are cancelled and marked failed so they don't block the dispatch chain. */ +/** Per-run timeout in minutes. Hung runs are ended so they cannot block later occurrences. */ export const CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING = 'chat.automations.runTimeoutMinutes'; /** Default for {@link CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING}. */ diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index d7323639de730b..86396758a14d4f 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -15,7 +15,8 @@ import { AgentHostDebugLogsArtifactKind, IAgentConnection, IAgentCreateSessionCo import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, TerminalLifecycleStatus, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification, ClientAnnotationsAction } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../../../../platform/agentHost/common/state/protocol/channels-automation/commands.js'; +import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, ResourceMkdirParams, ResourceMkdirResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; @@ -42,7 +43,7 @@ class MockAgentConnection implements IAgentConnection { readonly onMcpNotification: Event = Event.None; readonly initializeResult: IObservable = constObservable(undefined); - readonly dispatchedActions: { channel: string; action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction }[] = []; + readonly dispatchedActions: { channel: string; action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction }[] = []; readonly createdTerminals: CreateTerminalParams[] = []; readonly disposedTerminals: URI[] = []; readonly subscribedResources: URI[] = []; @@ -92,6 +93,9 @@ class MockAgentConnection implements IAgentConnection { async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } async completions(_params: CompletionsParams): Promise { return { items: [] }; } + async listAutomationTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { return { items: [] }; } + async runAutomation(_params: RunAutomationParams): Promise { throw new Error('Not implemented'); } + async fetchAutomationRuns(_params: FetchAutomationRunsParams): Promise { return {}; } async getCompletionTriggerCharacters(): Promise { return []; } async disposeSession(_session: URI): Promise { } async createChat(_session: URI, _chat: URI): Promise { } @@ -141,6 +145,9 @@ class MockAgentConnection implements IAgentConnection { }, }; } + getSubscriptionByChannel(_kind: StateComponents, _channel: string): IReference> { + throw new Error('Not implemented'); + } getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } @@ -150,7 +157,7 @@ class MockAgentConnection implements IAgentConnection { getActiveSubscriptions(): readonly IActiveSubscriptionInfo[] { return []; } - dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void { this.dispatchedActions.push({ channel, action }); } diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 6df1fe894caba7..be2db288af3412 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -23,7 +23,8 @@ import { AgentHostClientState, AgentHostProtocolClient } from '../../../../platf import type { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction, ClientAnnotationsAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../../../platform/agentHost/common/state/protocol/channels-automation/commands.js'; +import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import type { IRemoteWatchHandle } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult } from '../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ComponentToState, RootState, StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -194,6 +195,10 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().getSubscription(kind, resource, owner); } + getSubscriptionByChannel(kind: T, channel: string, owner: string): IReference> { + return this._requireClient().getSubscriptionByChannel(kind, channel, owner); + } + getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined { return this._protocolClient?.getSubscriptionUnmanaged(kind, resource); } @@ -206,7 +211,7 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._protocolClient?.getActiveSubscriptions() ?? []; } - dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction): void { this._protocolClient?.dispatch(channel, action); } @@ -258,6 +263,18 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().completions(params); } + listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise { + return this._requireClient().listAutomationTriggerDefinitions(params); + } + + runAutomation(params: RunAutomationParams): Promise { + return this._requireClient().runAutomation(params); + } + + fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { + return this._requireClient().fetchAutomationRuns(params); + } + getCompletionTriggerCharacters(): Promise { return this._requireClient().getCompletionTriggerCharacters(); }