Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/vs/sessions/AUTOMATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ Legacy `modelId`, `mode`, and `permissionLevel` fields remain decode and input a

At most one non-terminal run may occupy an Automation's active-run slot within one authority.

### Catalogue availability

Every Automation store exposes whether its complete catalogue is `loading`, `ready`, `unavailable`, or in `error`. An empty catalogue is authoritative only in the `ready` state. `loading` is reserved for initial provider discovery and authoritative snapshots that are still in flight. `unavailable` means a known provider catalogue cannot currently be reached without treating that condition as storage or migration failure.

Provider stores map their connection and persistence lifecycle into this provider-neutral state. Agent Host stores become ready when an authoritative catalogue snapshot and every source still participating in the projection are readable, independently of migration authority. Known disconnect, disabled capability, and unsupported capability are unavailable rather than perpetually loading.

`ProviderAutomationService` keeps the initial aggregate loading until all AfterRestored workbench contributions have completed provider registration. A provider-less window then settles to its legacy-store state, so a legacy-only empty catalogue can be authoritative. After provider settlement, the aggregate reports `error` when any current store fails, otherwise `loading` while any store is loading, `unavailable` while any store is unavailable, and `ready` only when all current stores are ready.

## Multi-host routing

VS Code may register one local Agent Host provider and multiple remote Agent Host providers at the same time. A remote connection has its own provider identity, Automation store, AHP catalogue, and migration state.
Expand Down Expand Up @@ -326,6 +334,7 @@ Updates that do not change the target remain allowed while an active run delays
11. Same-target edits preserve unknown template values unless the user explicitly changes them.
12. Retargeting does not carry a previous provider's template into the new authority.
13. Runtime policy and provider schema are revalidated for every run without treating saved configuration as a grant.
14. Consumers distinguish a confirmed empty catalogue from loading, unavailability, and failure through the provider-neutral catalogue state.

## Concrete behavior

Expand Down
13 changes: 11 additions & 2 deletions src/vs/sessions/browser/parts/customViewNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
*--------------------------------------------------------------------------------------------*/

import './media/customViewGridPart.css';
import { $, isAncestorOfActiveElement } from '../../../base/browser/dom.js';
import { $, addDisposableListener, EventType, getWindow, isAncestorOfActiveElement, scheduleAtNextAnimationFrame } from '../../../base/browser/dom.js';
import { DomScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js';
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
import { Disposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { autorun } from '../../../base/common/observable.js';
import { ScrollbarVisibility } from '../../../base/common/scrollable.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js';
Expand Down Expand Up @@ -98,6 +98,15 @@ export class CustomViewNode extends Disposable {
vertical: ScrollbarVisibility.Auto,
useShadows: false,
}));
this._register(addDisposableListener(scrollContent, EventType.SCROLL, () => {
this._scrollable.setScrollPosition({ scrollTop: scrollContent.scrollTop });
}));
const focusScrollSync = this._register(new MutableDisposable());
this._register(addDisposableListener(scrollContent, EventType.FOCUS_IN, () => {
focusScrollSync.value = scheduleAtNextAnimationFrame(getWindow(scrollContent), () => {
this._scrollable.setScrollPosition({ scrollTop: scrollContent.scrollTop });
});
}));
this._scrollable.getDomNode().classList.add('custom-view-body');
this.element.appendChild(this._scrollable.getDomNode());

Expand Down
62 changes: 53 additions & 9 deletions src/vs/sessions/contrib/automations/browser/automationDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser
import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { Link } from '../../../../platform/opener/browser/link.js';
import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js';
import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js';
import { hasNativeContextMenu } from '../../../../platform/window/common/window.js';
Expand All @@ -55,7 +56,7 @@ import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../..
import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js';
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js';
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js';
import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from '../../chat/browser/mobile/mobileWorkspacePickerSheet.js';
import { AutomationInputCompletions } from './automationInputCompletions.js';
Expand Down Expand Up @@ -500,6 +501,23 @@ const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomati
const AUTOMATIONS_WORKSPACE_PICKER_ACTION_ID = 'workbench.action.chat.renderAutomationsWorkspacePicker';
const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup';

export function getAutomationTargetHint(target: Pick<IFormState, 'isQuickChat' | 'folderUri' | 'providerId' | 'sessionTypeId'>, sessionTypes: readonly IProviderSessionType[]): string | undefined {
if (!target.isQuickChat && !target.folderUri) {
return localize('automation.form.targetHint.choose', "Choose a workspace or No workspace to see which agents can run this automation.");
}
if (sessionTypes.length === 0) {
return localize('automation.form.targetHint.unavailable', "No agents are currently available for this target.");
}
if (sessionTypes.length === 1
&& sessionTypes[0].sessionType.id === target.sessionTypeId
&& (!target.providerId || sessionTypes[0].providerId === target.providerId)) {
return target.isQuickChat
? localize('automation.form.targetHint.singleQuickChat', "Only {0} is available without a workspace. Choose a workspace to use a different agent.", sessionTypes[0].sessionType.label)
: localize('automation.form.targetHint.singleWorkspace', "Only {0} is available for this workspace. Change the workspace to use a different agent.", sessionTypes[0].sessionType.label);
}
return undefined;
}

type BranchLoadState = 'noFolder' | 'loadingRepository' | 'noRepository' | 'loadingBranches' | 'ready' | 'empty' | 'error';

function setAutomationControlVisible(container: HTMLElement, visible: boolean): void {
Expand Down Expand Up @@ -950,7 +968,7 @@ registerAction2(class OpenAutomationsWorkspacePickerAction extends Action2 {
menu: [{
id: MenuId.ChatInputSecondary,
group: 'navigation',
order: 0,
order: -2,
when: ChatContextKeys.inAutomationsDialog,
}],
});
Expand Down Expand Up @@ -1079,8 +1097,8 @@ export function renderForm(

// The picker is authoritative for the session type
const isolationModel = new AutomationIsolationModel(state);
const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader));
const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable<ISession | undefined>(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker', showChevron: false }));
const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader) && isolationModel.folderUriObs.read(reader) !== undefined);
const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable<ISession | undefined>(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker' }));
sessionTypePicker.setQuickChatSource(isolationModel.isQuickChatObs);
sessionTypePicker.setFolderSource(isolationModel.folderUriObs, {
initialPick: state.sessionTypeId
Expand Down Expand Up @@ -1115,6 +1133,7 @@ export function renderForm(

const workspacePicker = disposables.add(instantiationService.createInstance(MobileAutomationsWorkspacePicker, {
restoreFromSessions: false,
canRestoreWorkspace: () => false,
canSelectWorkspace: (folderUri, preferredProviderId) =>
canSelectAutomationWorkspace(folderUri, preferredProviderId, sessionsManagementService, workspaceTrustRequestService),
}));
Expand Down Expand Up @@ -1188,10 +1207,6 @@ export function renderForm(
}
}));

if (!state.isQuickChat && !state.folderUri && workspacePicker.selectedFolderUri) {
isolationModel.setWorkspace(workspacePicker.selectedFolderUri);
}

disposables.add(autorun(reader => {
isolationModel.isQuickChatObs.read(reader);
updateAutomationSessionTarget();
Expand Down Expand Up @@ -1339,6 +1354,33 @@ export function renderForm(
chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget);
chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") });
disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor));
const targetHint = DOM.append(promptSection, $('.automation-target-hint'));
const targetHintMessage = DOM.append(targetHint, $('span.automation-target-hint-message', {
role: 'status',
'aria-atomic': 'true',
}));
const chooseWorkspaceContainer = DOM.append(targetHint, $('span.automation-target-hint-action'));
disposables.add(instantiationService.createInstance(Link, chooseWorkspaceContainer, {
label: localize('automation.form.chooseWorkspace', "Choose Workspace"),
href: '#',
}, {
opener: () => workspacePicker.showPicker(),
}));
const selectedSessionTypeChanged = observableSignalFromEvent(targetHint, sessionTypePicker.onDidChangeSelectedPick);
disposables.add(autorun(reader => {
sessionTypesChanged.read(reader);
selectedSessionTypeChanged.read(reader);
const isQuickChat = isolationModel.isQuickChatObs.read(reader);
const folderUri = isolationModel.folderUriObs.read(reader);
const sessionTypes = isQuickChat
? sessionsManagementService.getQuickChatSessionTypes()
: folderUri ? sessionsManagementService.getSessionTypesForFolder(folderUri) : [];
const message = getAutomationTargetHint(state, sessionTypes);
setAutomationControlVisible(targetHint, message !== undefined);
if (targetHintMessage.textContent !== (message ?? '')) {
targetHintMessage.textContent = message ?? '';
}
}));
const sessionConfigurationRow = DOM.append(promptSection, $('.automation-form-row'));
const sessionConfigurationLabel = DOM.append(sessionConfigurationRow, $('span.automation-form-label', {
id: 'automation-session-configuration-label',
Expand Down Expand Up @@ -1383,6 +1425,8 @@ export function renderForm(
}));
DOM.hide(sessionConfigurationError);
disposables.add(autorun(reader => {
const hasTarget = isolationModel.isQuickChatObs.read(reader) || isolationModel.folderUriObs.read(reader) !== undefined;
setAutomationControlVisible(sessionConfigurationRow, hasTarget);
const availability = automationSessionDraftSynchronizer.availability.read(reader);
const pending = availability === 'pending';
const controlsUnavailable = availability !== 'available';
Expand Down Expand Up @@ -1614,7 +1658,7 @@ export class AutomationsWorkspacePicker extends WorkspacePicker {
const noWorkspace = this.targetModel?.isQuickChat === true;
const label = noWorkspace
? localize('automation.form.noWorkspace', "No workspace")
: workspace?.label ?? localize('pickWorkspace', "workspace");
: workspace?.label ?? localize('automation.form.selectWorkspace', "Select workspace");
const icon = noWorkspace ? Codicon.commentDiscussion : workspace?.icon ?? Codicon.project;

trigger.setAttribute('aria-label', workspace || noWorkspace
Expand Down
26 changes: 17 additions & 9 deletions src/vs/sessions/contrib/automations/browser/automationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isAutomationModelConfiguration,
} from '../../../../workbench/contrib/chat/common/automations/automation.js';
import {
AutomationCatalogueState,
type AutomationMutationGuard,
assertAutomationSessionTemplateAuthority,
IAutomationRunClaim,
Expand Down Expand Up @@ -122,14 +123,15 @@ export class AutomationStore extends Disposable implements IAutomationStore {

private readonly _automations: ISettableObservable<readonly IAutomationDescriptor[]>;
private readonly _runs: ISettableObservable<readonly IAutomationRun[]>;
private readonly _catalogueState: ISettableObservable<AutomationCatalogueState>;
private _now: () => Date;
private readonly _runsForCache = new Map<string, IObservable<readonly IAutomationRun[]>>();

private _lastSeenRevision = 0;
private _canCompleteMigration = true;

readonly automations: IObservable<readonly IAutomationDescriptor[]>;
readonly runs: IObservable<readonly IAutomationRun[]>;
readonly catalogueState: IObservable<AutomationCatalogueState>;

constructor(
private readonly storageKey: string,
Expand All @@ -144,14 +146,15 @@ export class AutomationStore extends Disposable implements IAutomationStore {

const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION));
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<readonly IAutomationDescriptor[]>(this, initial.automations);
this._runs = observableValue<readonly IAutomationRun[]>(this, initial.runs);
this._catalogueState = observableValue(this, result.kind === 'ledger' ? 'ready' : 'error');
this.automations = this._automations;
this.runs = this._runs;
this.catalogueState = this._catalogueState;

this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, this.storageKey, this._store)(() => {
this.refreshFromStorage();
Expand All @@ -168,7 +171,7 @@ export class AutomationStore extends Disposable implements IAutomationStore {
}

canCompleteMigration(): boolean {
return this._canCompleteMigration;
return this._catalogueState.get() === 'ready';
}

runsFor(automationId: string): IObservable<readonly IAutomationRun[]> {
Expand Down Expand Up @@ -475,9 +478,11 @@ export class AutomationStore extends Disposable implements IAutomationStore {
while (true) {
const readResult = this.readLedger(raw);
if (readResult.kind === 'unsupportedSchema') {
this._catalogueState.set('error', undefined);
throw new Error('Cannot modify automations: storage was written by a newer version');
}
if (readResult.kind === 'invalid') {
this._catalogueState.set('error', undefined);
throw new Error('Cannot modify automations: persisted storage contains data this version cannot safely interpret');
}

Expand Down Expand Up @@ -512,30 +517,33 @@ export class AutomationStore extends Disposable implements IAutomationStore {
}
}

private acceptLedger(ledger: ILedger, revision: number): void {
private acceptLedger(ledger: ILedger, revision: number, catalogueState: AutomationCatalogueState = 'ready'): void {
if (revision < this._lastSeenRevision) {
if (catalogueState === 'error') {
this._catalogueState.set(catalogueState, undefined);
}
return;
}
this.setLedger(ledger, revision);
this.setLedger(ledger, revision, catalogueState);
}

private setLedger(ledger: ILedger, revision: number): void {
private setLedger(ledger: ILedger, revision: number, catalogueState: AutomationCatalogueState = 'ready'): void {
this._lastSeenRevision = revision;
transaction(tx => {
this._automations.set(ledger.automations, tx);
this._runs.set(ledger.runs, tx);
this._catalogueState.set(catalogueState, tx);
});
}

private refreshFromStorage(): void {
const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION));
if (result.kind === 'unsupportedSchema') {
this._canCompleteMigration = false;
this._catalogueState.set('error', undefined);
return;
}

this._canCompleteMigration = result.kind === 'ledger';
this.acceptLedger(result.ledger, result.revision);
this.acceptLedger(result.ledger, result.revision, result.kind === 'ledger' ? 'ready' : 'error');
}

private readLedger(raw: string | undefined): ReadLedgerResult {
Expand Down
13 changes: 8 additions & 5 deletions src/vs/sessions/contrib/automations/browser/automationTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class ListAutomationsTool implements IToolImpl {
icon: Codicon.calendar,
displayName: localize('automation.tool.list.displayName', "List Automations"),
userDescription: localize('automation.tool.list.userDescription', "List scheduled agent automations"),
modelDescription: 'List all configured scheduled automations and their stable IDs, editable fields, targets, and timing metadata. Use this before configureAutomation, runAutomation, or deleteAutomation when acting on an existing automation. This tool never changes automation state.',
modelDescription: 'List all currently available scheduled automations and their stable IDs, editable fields, targets, and timing metadata. The result includes catalogueState; only "ready" means the list is complete, so never interpret an empty non-ready result as no configured automations. Use this before configureAutomation, runAutomation, or deleteAutomation when acting on an existing automation. This tool never changes automation state.',
source: ToolDataSource.Internal,
when: automationToolWhen,
runsInWorkspace: false,
Expand All @@ -130,11 +130,14 @@ export class ListAutomationsTool implements IToolImpl {
return automationToolError('Automations are disabled.');
}

const catalogueState = this.automationService.catalogueState.get();
const automations = this.automationService.automations.get().map(toAutomationToolOutput);
const result = automationToolResult(JSON.stringify({ automations }, undefined, 2));
result.toolResultMessage = automations.length === 1
? localize('automation.tool.list.result.singular', "Listed 1 automation")
: localize('automation.tool.list.result.plural', "Listed {0} automations", automations.length);
const result = automationToolResult(JSON.stringify({ catalogueState, automations }, undefined, 2));
result.toolResultMessage = catalogueState !== 'ready'
? localize('automation.tool.list.result.incomplete', "Listed {0} available automations; catalogue is incomplete", automations.length)
: automations.length === 1
? localize('automation.tool.list.result.singular', "Listed 1 automation")
: localize('automation.tool.list.result.plural', "Listed {0} automations", automations.length);
return result;
}
}
Expand Down
Loading