Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions src/vs/sessions/SESSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ src/vs/sessions/contrib/providers/
└── remoteAgentHost/ # Remote agent host provider (one instance per connection)
```

Providers can expose `automations` to own durable Automation entities and run history. `ProviderAutomationService` aggregates these stores behind `IAutomationService`, routes mutations to the owning store, and keeps the legacy global ledger mounted while entries migrate idempotently by Automation and run ID.
Startup recovery attempts every available store independently, so one unavailable provider does not block stale-run recovery in the remaining stores.
Legacy migration also isolates failures by Automation, leaving failed entries in legacy storage while continuing with later entries.

Providers can import from all layers below them (core, services, non-provider contribs). **Non-provider contribs must NOT import from providers.** Shared symbols should be extracted to `services/` or `common/`.

Permission picker labels and descriptions use provider-neutral language and stay aligned across Copilot Chat and Agent Host providers. Agent Host mode and running-session permission pickers use provider-specific list options in both the workbench and Agents window so their descriptive text has a consistent minimum width. `chat.defaultConfiguration.approvals` sets the initial permission level for new sessions using `default`, `assisted`, or `allowAll`; the live session config continues to use the Agent Host protocol's `autoApprove` value.
Expand Down
67 changes: 59 additions & 8 deletions src/vs/sessions/contrib/automations/browser/automationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
IGuardedAutomationUpdateResult,
serializeAutomationEditableState,
IUpdateAutomationOptions,
IAutomationStore,
IUpdateAutomationRunOptions,
} from '../../../../workbench/contrib/chat/common/automations/automationService.js';
import { publishAutomationCreated, publishAutomationDeleted, publishAutomationUpdated } from '../../../../workbench/contrib/chat/common/automations/automationTelemetry.js';
Expand Down Expand Up @@ -109,9 +110,7 @@ type ReadLedgerResult =
| { kind: 'ledger'; ledger: ILedger; revision: number }
| { kind: 'unsupportedSchema' };

export class AutomationService extends Disposable implements IAutomationService {

declare readonly _serviceBrand: undefined;
export class AutomationStore extends Disposable implements IAutomationStore {

private readonly _automations: ISettableObservable<readonly IAutomation[]>;
private readonly _runs: ISettableObservable<readonly IAutomationRun[]>;
Expand All @@ -124,6 +123,7 @@ export class AutomationService extends Disposable implements IAutomationService
readonly runs: IObservable<readonly IAutomationRun[]>;

constructor(
private readonly storageKey: string,
@IStorageService private readonly storageService: IStorageService,
@ILogService private readonly logService: ILogService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
Expand All @@ -133,7 +133,7 @@ export class AutomationService extends Disposable implements IAutomationService

this._now = () => new Date();

const result = this.readLedger(this.storageService.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION));
const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION));
const initial = result.kind === 'ledger' ? result.ledger : EMPTY_LEDGER;
if (result.kind === 'ledger') {
this._lastSeenRevision = result.revision;
Expand All @@ -143,7 +143,7 @@ export class AutomationService extends Disposable implements IAutomationService
this.automations = this._automations;
this.runs = this._runs;

this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, AUTOMATION_STORAGE_KEY, this._store)(() => {
this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, this.storageKey, this._store)(() => {
this.refreshFromStorage();
}));
}
Expand Down Expand Up @@ -269,6 +269,42 @@ export class AutomationService extends Disposable implements IAutomationService
publishAutomationDeleted(this.telemetryService, existing);
}

async importAutomation(automation: IAutomation, runs: readonly IAutomationRun[]): Promise<void> {
await this.mutateLedger(ledger => {
const hasAutomation = ledger.automations.some(candidate => candidate.id === automation.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: An existing automation ID is treated as equivalent without comparing the stored payload, and same-ID runs are also skipped without comparing their payloads. Migration then removes the unchanged legacy snapshot, so divergent provider state silently wins and legacy automation/run data is lost. Please report a conflict for divergent snapshots and remove the source only after destination equivalence is established.

const existingRunIds = new Set(ledger.runs.map(run => run.id));
const missingRuns = runs.filter(run => !existingRunIds.has(run.id));
if (hasAutomation && missingRuns.length === 0) {
return { kind: 'noChange', result: undefined };
}
return {
kind: 'commit',
ledger: {
automations: hasAutomation ? ledger.automations : [automation, ...ledger.automations],
runs: [...missingRuns, ...ledger.runs],
},
result: undefined,
};
});
}

async removeAutomationForMigration(id: string): Promise<void> {
await this.mutateLedger(ledger => {
if (!ledger.automations.some(automation => automation.id === id)) {
return { kind: 'noChange', result: undefined };
}
return {
kind: 'commit',
ledger: {
automations: ledger.automations.filter(automation => automation.id !== id),
runs: ledger.runs.filter(run => run.automationId !== id),
},
result: undefined,
};
});
this._runsForCache.delete(id);
}

async recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise<IAutomationRunClaim> {
const now = this._now();
const startedAt = now.toISOString();
Expand Down Expand Up @@ -378,7 +414,7 @@ export class AutomationService extends Disposable implements IAutomationService
//#region Persistence

private async mutateLedger<T>(mutate: (ledger: ILedger) => ILedgerMutation<T>, mutationGuard?: AutomationMutationGuard): Promise<T> {
let raw = await this.automationStorageService.read();
let raw = await this.automationStorageService.read(this.storageKey);
while (true) {
const readResult = this.readLedger(raw);
if (readResult.kind === 'unsupportedSchema') {
Expand All @@ -404,7 +440,7 @@ export class AutomationService extends Disposable implements IAutomationService
};
const newValue = JSON.stringify(serialized);
mutationGuard?.();
const writeResult = await this.automationStorageService.compareAndSwap(raw, newValue);
const writeResult = await this.automationStorageService.compareAndSwap(this.storageKey, raw, newValue);
if (writeResult.swapped) {
this.setLedger(ledger, revision);
return mutation.result;
Expand Down Expand Up @@ -432,10 +468,11 @@ export class AutomationService extends Disposable implements IAutomationService
}

private refreshFromStorage(): void {
const result = this.readLedger(this.storageService.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION));
const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION));
if (result.kind === 'unsupportedSchema') {
return;
}

this.acceptLedger(result.ledger, result.revision);
}

Expand Down Expand Up @@ -499,6 +536,20 @@ export class AutomationService extends Disposable implements IAutomationService
//#endregion
}

export class AutomationService extends AutomationStore implements IAutomationService {

declare readonly _serviceBrand: undefined;

constructor(
@IStorageService storageService: IStorageService,
@ILogService logService: ILogService,
@ITelemetryService telemetryService: ITelemetryService,
@IAutomationStorageService automationStorageService: IAutomationStorageService,
) {
super(AUTOMATION_STORAGE_KEY, storageService, logService, telemetryService, automationStorageService);
}
}

function serializeAutomation(a: IAutomation): ISerializedAutomation {
return {
id: a.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { BrowserStorageService } from '../../../../workbench/services/storage/browser/storageService.js';
import { AUTOMATION_STORAGE_KEY, IAutomationStorageCompareAndSwapResult, IAutomationStorageService } from '../common/automationStorageService.js';
import { IAutomationStorageCompareAndSwapResult, IAutomationStorageService } from '../common/automationStorageService.js';

/**
* Uses an IndexedDB transaction so automation writes remain atomic across browser tabs.
Expand All @@ -25,11 +25,11 @@ export class BrowserAutomationStorageService implements IAutomationStorageServic
this.storageService = storageService;
}

async read(): Promise<string | undefined> {
return this.storageService.getApplicationStorageValue(AUTOMATION_STORAGE_KEY);
async read(key: string): Promise<string | undefined> {
return this.storageService.getApplicationStorageValue(key);
}

async compareAndSwap(expectedValue: string | undefined, newValue: string): Promise<IAutomationStorageCompareAndSwapResult> {
return this.storageService.compareAndSwapApplicationStorage(AUTOMATION_STORAGE_KEY, expectedValue, newValue);
async compareAndSwap(key: string, expectedValue: string | undefined, newValue: string): Promise<IAutomationStorageCompareAndSwapResult> {
return this.storageService.compareAndSwapApplicationStorage(key, expectedValue, newValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING, CHAT_A
import { AutomationDialogService } from './automationDialogService.js';
import { AutomationRunner } from './automationRunner.js';
import { AutomationScheduler } from './automationScheduler.js';
import { AutomationService } from './automationService.js';
import { ProviderAutomationService } from './providerAutomationService.js';
import { BrowserAutomationStorageService } from './automationStorageService.js';
import { AutomationToolsContribution } from './automationTools.js';
import { IAutomationStorageService } from '../common/automationStorageService.js';

registerSingleton(IAutomationStorageService, BrowserAutomationStorageService, InstantiationType.Delayed);
registerSingleton(IAutomationService, AutomationService, InstantiationType.Delayed);
registerSingleton(IAutomationService, ProviderAutomationService, InstantiationType.Delayed);
registerSingleton(IAutomationRunner, AutomationRunner, InstantiationType.Delayed);
registerSingleton(IAutomationDialogService, AutomationDialogService, InstantiationType.Delayed);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* 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 { derived, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IAutomation, IAutomationRun, AutomationRunTrigger } from '../../../../workbench/contrib/chat/common/automations/automation.js';
import { AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { ISessionsProviderAutomations } from '../../../services/sessions/common/sessionsProvider.js';
import { AutomationService } from './automationService.js';

interface IAutomationStoreEntry {
readonly providerId: string | undefined;
readonly store: ISessionsProviderAutomations;
}

export class ProviderAutomationService extends Disposable implements IAutomationService {

declare readonly _serviceBrand: undefined;

private readonly legacyStore: AutomationService;
private readonly providersChanged;
private readonly migrationSequencer = new Sequencer();
private migrationPromise: Promise<void> = Promise.resolve();
private readonly runsForCache = new Map<string, IObservable<readonly IAutomationRun[]>>();

readonly automations: IObservable<readonly IAutomation[]>;
readonly runs: IObservable<readonly IAutomationRun[]>;

constructor(
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
@IInstantiationService instantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService,
) {
super();
this.legacyStore = this._register(instantiationService.createInstance(AutomationService));
this.providersChanged = observableSignalFromEvent(this, sessionsProvidersService.onDidChangeProviders);
this.automations = derived(this, reader => {
this.providersChanged.read(reader);
return distinctById(
this.getStores().flatMap(entry => [...entry.store.automations.read(reader)])
).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
});
this.runs = derived(this, reader => {
this.providersChanged.read(reader);
return distinctById(
this.getStores().flatMap(entry => [...entry.store.runs.read(reader)])
).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
});
this._register(sessionsProvidersService.onDidChangeProviders(event => {
if (event.added.some(provider => provider.automations)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Providers registered after construction only queue legacy migration. Stale-run recovery is a separate one-shot operation, so a late provider can expose persisted running rows that are never marked failed and can continue blocking new run claims. Please run stale recovery as part of the added-provider lifecycle and cover late registration with a test.

this.queueMigration();
}
Comment thread
benvillalobos marked this conversation as resolved.
}));
this.queueMigration();
}

getAutomation(id: string): IAutomation | undefined {
return this.findAutomationStore(id)?.store.getAutomation(id);
}

runsFor(automationId: string): IObservable<readonly IAutomationRun[]> {
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<IAutomation> {
return this.getCreationStore(options).createAutomation(options, mutationGuard);
}

updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise<IAutomation> {
return this.requireAutomationStore(id).updateAutomation(id, patch);
Comment thread
benvillalobos marked this conversation as resolved.
Outdated
}

updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, expected: IAutomation, mutationGuard?: AutomationMutationGuard): Promise<IGuardedAutomationUpdateResult> {
return this.requireAutomationStore(id).updateAutomationIfUnchanged(id, patch, expected, mutationGuard);
}

async deleteAutomation(id: string, mutationGuard?: AutomationMutationGuard): Promise<void> {
await this.requireAutomationStore(id).deleteAutomation(id, mutationGuard);
this.runsForCache.delete(id);
}

recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise<IAutomationRunClaim> {
return this.requireAutomationStore(automationId).recordRunStart(automationId, trigger, leaderWindowId);
}

updateRun(runId: string, patch: IUpdateAutomationRunOptions): Promise<IAutomationRun | undefined> {
const store = this.findRunStore(runId);
return store ? store.updateRun(runId, patch) : Promise.resolve(undefined);
}

deleteRun(runId: string): Promise<void> {
const store = this.findRunStore(runId);
return store ? store.deleteRun(runId) : Promise.resolve();
}

getActiveRunFor(automationId: string): IAutomationRun | undefined {
return this.findAutomationStore(automationId)?.store.getActiveRunFor(automationId);
}

async markStaleRunsFailed(reason: string): Promise<void> {
await this.migrationPromise;
const stores = this.getStores();
const results = await Promise.allSettled(stores.map(entry => entry.store.markStaleRunsFailed(reason)));
for (let index = 0; index < results.length; index++) {
const result = results[index];
if (result.status === 'rejected') {
const providerId = stores[index].providerId ?? 'legacy';
this.logService.error(`[ProviderAutomationService] Failed to recover stale Automation runs for '${providerId}'.`, result.reason);
}
}
}

waitForMigrationForTesting(): Promise<void> {
return this.migrationPromise;
}

private getStores(): IAutomationStoreEntry[] {
const providerStores = this.sessionsProvidersService.getProviders()
.filter(provider => provider.automations)
.map(provider => ({ providerId: provider.id, store: provider.automations! }));
return [...providerStores, { providerId: undefined, store: this.legacyStore }];
}

private getCreationStore(options: ICreateAutomationOptions): ISessionsProviderAutomations {
const providerId = options.target.providerId;
if (providerId) {
const providerStore = this.sessionsProvidersService.getProvider(providerId)?.automations;
if (providerStore) {
return providerStore;
}
}

return this.legacyStore;
}

private findAutomationStore(id: string): IAutomationStoreEntry | undefined {
return this.getStores().find(entry => !!entry.store.getAutomation(id));
}

private requireAutomationStore(id: string): ISessionsProviderAutomations {
const entry = this.findAutomationStore(id);
if (!entry) {
throw new Error(`Automation '${id}' does not exist.`);
}
return entry.store;
}

private findRunStore(runId: string): ISessionsProviderAutomations | undefined {
return this.getStores().find(entry => entry.store.runs.get().some(run => run.id === runId))?.store;
}

private queueMigration(): void {
this.migrationPromise = this.migrationSequencer.queue(() => this.migrateLegacyAutomations()).catch(error => {
this.logService.error('[ProviderAutomationService] Failed to migrate legacy Automations.', error);
});
}

private async migrateLegacyAutomations(): Promise<void> {
for (const automation of [...this.legacyStore.automations.get()]) {
const providerId = automation.target.providerId;
if (!providerId) {
continue;
}
const providerStore = this.sessionsProvidersService.getProvider(providerId)?.automations;
if (!providerStore) {
continue;
}
try {
await providerStore.importAutomation(automation, this.legacyStore.runsFor(automation.id).get());
await this.legacyStore.removeAutomationForMigration(automation.id);
Comment thread
benvillalobos marked this conversation as resolved.
Outdated
} catch (error) {
this.logService.error(`[ProviderAutomationService] Failed to migrate Automation '${automation.id}' to provider '${providerId}'.`, error);
}
}
}

}

function distinctById<T extends { readonly id: string }>(items: readonly T[]): T[] {
const result: T[] = [];
const seen = new Set<string>();
for (const item of items) {
if (!seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
return result;
}
Loading
Loading