Files
vscode/scripts/sync-agent-host-protocol.ts
T
33e6a5d6f3 automations: feat: migrate execution to Agent Host Protocol (#331796)
* automations: feat: migrate execution to Agent Host Protocol

Move Automation definitions, scheduling, run lifecycle, and persistence into the Agent Host while safely migrating legacy VS Code data and retaining older-host fallback behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 142bb750-abf2-4b29-91b8-1e9ab2444635

* automations: guard Agent Host migration cutover

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* automations: optimize sparse cron evaluation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* automations: gate initial Run advertisement on enabled state

Match the canRun composite used by handleConfigurationChanged so a create that arrives while chat.automations.enabled is false does not advertise Run.

* automations/ahp: gate SessionWorkingDirectoryReplaced action

Bring Replaced to parity with Set/Removed at the working-directory gate:
extend the action union, canonicalize both URIs in the resolver, enforce
editor-only client and provider capability at _dispatchActionNow, and
include Replaced in the customization-enablement listener.

Also honors the Removed contract for primaryReplacement: rejects index-0
removal when the agent advertises primaryReplacement.

* automations/ahp: enforce single-run invariant in schedule claim loop

Break out of the trigger loop once a schedule trigger has been claimed
for an Automation, after advancing that trigger's cursor. Prevents two
simultaneously-due schedule triggers on one Automation from both starting
sessions and violating the one-non-terminal-run-per-Automation invariant.
Deferred triggers keep their cursors untouched so their firings are
re-evaluated on the next tick rather than dropped.

* automations/ahp: coalesce simultaneously-due schedule triggers into one run

Two schedule triggers on one Automation whose past-due cursors land in
the same claim tick now coalesce into a single run. Catch-up is
idempotent: one run at now, regardless of how many missed firings a
sibling trigger also carries. The claim block skips when another trigger
has already claimed for this Automation this tick, but the deferred
cursor still rolls forward to its next cron occurrence so it does not
re-fire on the next tick. Replaces the earlier break-after-claim
approach from e2c2657, which serialized the deferred firing back-to-back.

* automations/ahp: gate Run authority on legacy import until source is durably removed

The migration path published imported snapshots with Run granted before the legacy source row was CAS-removed, creating a double-authority window where both schedulers could dispatch the same occurrence. If the removal failed, the window became permanent.

Stage imports with a pending meta flag, centralize the Run/Remove permission check in the host, restore Remove when the flag clears, gate scheduling ownership on the flag, and add an acknowledge hook so cross-provider retargets clear the pending state after the source is durably gone. Recovery drains stranded pending rows on reconnect.

* automations: finish Agent Host merge integration

Co-authored-by: benvillalobos <4691428+benvillalobos@users.noreply.github.com>

* test: mirror host automation migration authority

Co-authored-by: benvillalobos <4691428+benvillalobos@users.noreply.github.com>

* agentHost: restore main's reject of SessionWorkingDirectoryReplaced

The merge collapsed main's two-block structure for working-directory actions back into one, dropping the explicit reject for `session/workingDirectoryReplaced`. No provider advertises `primaryReplacement` and the host has no backend side effect for the action, so the reducer would apply an unvalidated mutation. Restore the standalone reject before the EditorWindow-gated block for Set / Removed.

* signing commit

* signing commit

* automations: use family guards for dispatch, matching existing pattern

Recreate the pre-existing dispatch-guard convention rather than switching
this hot path to isClientDispatchable. The generic check pulled in synced
protocol code and widened the scope of this change. Automation and
automation-run actions now flow through family guards, consistent with how
session, chat, terminal, changeset, and annotations actions are already
handled. The family-vs-permission gap this restores is pre-existing and
tracked for maintainer follow-up.

* automations: drop reducer-helpers sync patch, no longer needed

The dispatch guard no longer uses isClientDispatchable, so nothing imports
the synced reducer-helpers.ts. Its generated-source compatibility patch only
existed to widen that helper's signature for the guard, so remove it and let
the file sync verbatim. The state.ts dead-import patch stays until the synced
AHP revision picks up the upstream fix.

* agentHost: separate subscription resources and channels

Keep URI-based subscription APIs narrow while preserving exact AHP catalogue channels. Mark failed reconnect restorations by channel and cover the exact-channel path with regressions.

* automations: give the catalogue channel a round-trippable authority

The catalogue channel constant was `ahp-automations://`, which is not a
round-trippable URI. `URI.parse('ahp-automations://').toString()` drops the
empty authority and yields `ahp-automations:`, so a channel serialized on the
client no longer matched the catalogue check on the host.

Append a `catalog` authority so the URI survives a parse/toString round-trip.
Comparing catalogue channels as URIs everywhere (ResourceMap/isEqual) remains
the intended followup.

* automations: key the catalogue channel like every other channel

Now that the catalogue channel URI round-trips through parse/toString, its
subscription key no longer needs to preserve the raw channel string. Drop the
`_subscriptionChannel` helper and its automation-catalogue special case, and
key every channel through `_subscriptionResource` by its parsed URI.

Comparing channels as URIs everywhere (ResourceMap/isEqual) remains the
intended followup.

* automations: use shared action family routing

Use dedicated automation channels for subscription relevance and reuse the canonical action-family guards in state management. This avoids silent drift when the protocol adds actions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8c6d543f-7777-4e8b-9371-3e39a0842293

* automations: inject the automation service as a collaborator

AgentService received the automation service through a post-construction setAutomationService setter, leaving a definite-assignment field and a one-off wiring step. It depends on AgentService only through the lazy callback adapter, so it can be built first and passed in the collaborators bag like every other dependency. Its constructor installs durable state without firing emitters, so the earlier ordering is safe.

* automations: key subscriptions by URI through ResourceMap

The subscription map had been changed from a ResourceMap to a Map<string> keyed by a synthetic getComparisonKey string, purely to hold the lossy catalogue channel under a raw key. With the catalogue channel now round-trippable and the special-case keying gone, every entry keys by a real URI again. Restores the ResourceMap that main uses and drops the synthetic key from the entry type, the resource helper, and all fifteen call sites.

* automations: revert subscribe callbacks to URI

The subscription manager threaded raw channel strings through _subscribe/_unsubscribe to dodge a lossy round-trip on the catalogue channel. Now that the catalogue URI round-trips, revert those callbacks to (resource: URI) to match main. The wire boundary keeps its .toString() serialization in the protocol client.

* automations: migrate legacy definitions to native AHP state

Translate legacy automations at the client boundary instead of persisting editor projection metadata. Derive the compatibility view from host state and canonicalize supported round trips.

* automations: stabilize legacy target serialization for AHP migration

Serialize folderUri as explicit URI components instead of URI.toJSON().
toJSON() only emits the lazily cached fsPath and formatted fields once
they have been accessed, so two URIs for the same folder could serialize
differently. That made the snapshot equality check during Agent Host
migration fail with "kept changing while migrating" for every
folder-target automation, blocking migration indefinitely.

Reads already go through URI.revive, so existing ledger data stays
compatible in both directions.

* automations: ignore rejected chat actions when finalizing runs

_handleEnvelope finalized an automation run on ChatTurnComplete,
ChatTurnCancelled, or ChatError but did not check rejectionReason. A
rejected action never reached authoritative host state, so applying it
marked a still-live run terminal and orphaned its session. Guard against
rejected envelopes before finalizing, matching the sessions provider's
action handler.

* automations: salvage valid legacy ledger entries

Keep valid automations writable when individual persisted rows are malformed. Update migration coverage and compare round-tripped URI resources without relying on cache state.

* automations: recover corrupt legacy run archives

Salvage valid archived runs and repair unreadable current-version archives during import. Preserve fail-closed handling for unsupported newer versions.

* automations: wait for provider migration before wakeup

Refresh pending automations only after initial provider migration succeeds. Apply the same ordering when a failed provider migration is retried.

* automations: reject inactive catalog subscriptions

Apply the standard subscription cancellation guard before adding an automation catalogue subscriber.

* automations: surface pending import drain failures

* automations: acknowledge migrated snapshots

* signing commit

---------

Co-authored-by: Ben Villalobos <4691428+benvillalobos@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ben Villalobos <bevillal@microsoft.com>
Copilot-Session: 142bb750-abf2-4b29-91b8-1e9ab2444635
Copilot-Session: 8c6d543f-7777-4e8b-9371-3e39a0842293
2026-08-27 14:29:23 -07:00

297 lines
11 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Copies type definitions from the sibling `agent-host-protocol` repo into
// `src/vs/platform/agentHost/common/state/protocol/`. Run via:
//
// npx tsx scripts/sync-agent-host-protocol.ts
//
// Source layout is preserved verbatim: every `.ts` file under
// `agent-host-protocol/types/` (notably the `common/` and `channels-*` folders)
// is copied into the matching subfolder of `protocol/`. Test fixtures
// (`test-cases/`), `*.test.ts` files, and `index.ts` are skipped.
//
// Transformations applied:
// 1. Converts 2-space indentation to tabs.
// 2. Merges duplicate imports from the same module.
// 3. Formats with the project's tsfmt.json settings.
// 4. Adds Microsoft copyright header.
//
// URI stays as `string` (the protocol's canonical representation). VS Code code
// should call `URI.parse()` at point-of-use where a URI class is needed.
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import * as ts from 'typescript';
const ROOT = path.resolve(__dirname, '..');
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');
// Load tsfmt.json formatting options once
const TSFMT_PATH = path.join(ROOT, 'tsfmt.json');
const FORMAT_OPTIONS: ts.FormatCodeSettings = JSON.parse(fs.readFileSync(TSFMT_PATH, 'utf-8'));
/**
* Formats a TypeScript source string using the TypeScript language service
* formatter with the project's tsfmt.json settings.
*/
function formatTypeScript(content: string, fileName: string): string {
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => ({}),
getScriptFileNames: () => [fileName],
getScriptVersion: () => '1',
getScriptSnapshot: (name: string) => name === fileName ? ts.ScriptSnapshot.fromString(content) : undefined,
getCurrentDirectory: () => ROOT,
getDefaultLibFileName: () => '',
fileExists: () => false,
readFile: () => undefined,
};
const ls = ts.createLanguageService(host);
const edits = ls.getFormattingEditsForDocument(fileName, FORMAT_OPTIONS);
// Apply edits in reverse order to preserve offsets
for (let i = edits.length - 1; i >= 0; i--) {
const edit = edits[i];
content = content.substring(0, edit.span.start) + edit.newText + content.substring(edit.span.start + edit.span.length);
}
ls.dispose();
return content;
}
const COPYRIGHT = `/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/`;
const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts';
/**
* Files and directories to exclude when discovering protocol sources. Anything
* else under `types/` is copied verbatim into `protocol/`, preserving the
* subdirectory layout (e.g. `types/channels-session/state.ts` →
* `protocol/channels-session/state.ts`).
*/
const EXCLUDE_DIR_NAMES = new Set([
'test-cases', // reducer test fixtures
'node_modules',
]);
const EXCLUDE_FILE_NAMES = new Set([
'tsconfig.json',
'message-checks.ts',
'index.ts', // protocol's public entry point — VS Code has its own re-export layout
]);
/**
* Walks `TYPES_DIR` recursively and yields `{ src, dest }` pairs (relative
* to `TYPES_DIR` / `DEST_DIR` respectively) for every `.ts` file that should
* be synced. Test files (`*.test.ts`) and the excluded names above are
* skipped. Yields a stable order so the output is reproducible.
*/
function discoverSourceFiles(): { src: string; dest: string }[] {
const results: { src: string; dest: string }[] = [];
function walk(dir: string, relBase: string): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (EXCLUDE_DIR_NAMES.has(entry.name)) {
continue;
}
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
walk(path.join(dir, entry.name), rel);
} else if (entry.isFile()) {
if (EXCLUDE_FILE_NAMES.has(entry.name)) {
continue;
}
if (!entry.name.endsWith('.ts')) {
continue;
}
if (entry.name.endsWith('.test.ts')) {
continue;
}
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
results.push({ src: rel, dest: rel });
}
}
}
walk(TYPES_DIR, '');
results.sort((a, b) => a.dest.localeCompare(b.dest));
return results;
}
function getSourceCommitHash(): string {
try {
return execSync('git rev-parse --short HEAD', { cwd: PROTOCOL_REPO, encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
function stripExistingHeader(content: string): string {
return content.replace(/^\/\*\*?[\s\S]*?\*\/\s*/, '');
}
function convertIndentation(content: string): string {
const lines = content.split('\n');
return lines.map(line => {
const match = line.match(/^( +)/);
if (!match) {
return line;
}
const spaces = match[1].length;
const tabs = Math.floor(spaces / 2);
const remainder = spaces % 2;
return '\t'.repeat(tabs) + ' '.repeat(remainder) + line.slice(spaces);
}).join('\n');
}
/**
* Merges duplicate imports from the same module.
* Combines `import type { A }` and `import { B }` from the same module into
* `import { B, type A }` to satisfy the no-duplicate-imports lint rule.
*/
function mergeDuplicateImports(content: string): string {
// Normalize line endings so the `$`-anchored import regexes below match
// regardless of whether the source was checked out with CRLF or LF.
content = content.replace(/\r\n/g, '\n');
// Collapse multi-line imports into single lines first
content = content.replace(/import\s+(type\s+)?\{([^}]+)\}\s+from\s+'([^']+)';/g, (_match, typeKeyword, names, mod) => {
const collapsed = names.replace(/\s+/g, ' ').trim();
return typeKeyword ? `import type { ${collapsed} } from '${mod}';` : `import { ${collapsed} } from '${mod}';`;
});
const importsByModule = new Map<string, { typeNames: string[]; valueNames: string[] }>();
const otherLines: string[] = [];
const seenModules = new Set<string>();
for (const line of content.split('\n')) {
const typeMatch = line.match(/^import type \{([^}]+)\} from '([^']+)';$/);
const valueMatch = line.match(/^import \{([^}]+)\} from '([^']+)';$/);
if (typeMatch) {
const [, names, mod] = typeMatch;
if (!importsByModule.has(mod)) {
importsByModule.set(mod, { typeNames: [], valueNames: [] });
}
importsByModule.get(mod)!.typeNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0));
if (!seenModules.has(mod)) {
seenModules.add(mod);
otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`);
}
} else if (valueMatch) {
const [, names, mod] = valueMatch;
if (!importsByModule.has(mod)) {
importsByModule.set(mod, { typeNames: [], valueNames: [] });
}
importsByModule.get(mod)!.valueNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0));
if (!seenModules.has(mod)) {
seenModules.add(mod);
otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`);
}
} else {
otherLines.push(line);
}
}
return otherLines.map(line => {
if (line.startsWith('__IMPORT_PLACEHOLDER__')) {
const mod = line.substring('__IMPORT_PLACEHOLDER__'.length);
const entry = importsByModule.get(mod)!;
const uniqueTypes = [...new Set(entry.typeNames)];
const uniqueValues = [...new Set(entry.valueNames)];
if (uniqueValues.length > 0 && uniqueTypes.length > 0) {
const allNames = [...uniqueValues, ...uniqueTypes.map(n => `type ${n}`)];
return `import { ${allNames.join(', ')} } from '${mod}';`;
} else if (uniqueValues.length > 0) {
return `import { ${uniqueValues.join(', ')} } from '${mod}';`;
} else {
return `import type { ${uniqueTypes.join(', ')} } from '${mod}';`;
}
}
return line;
}).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');
content = stripExistingHeader(content);
// 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');
const header = `${COPYRIGHT}\n\n${BANNER}\n`;
content = header + '\n' + content;
if (!content.endsWith('\n')) {
content += '\n';
}
const destPath = path.join(DEST_DIR, dest);
fs.mkdirSync(path.dirname(destPath), { recursive: true });
content = formatTypeScript(content, dest);
fs.writeFileSync(destPath, content, 'utf-8');
console.log(` ${dest}`);
}
// ---- Main -------------------------------------------------------------------
function main() {
if (!fs.existsSync(TYPES_DIR)) {
console.error(`ERROR: Cannot find ${TYPES_DIR}`);
console.error('Clone agent-host-protocol as a sibling of the VS Code repo:');
console.error(' git clone git@github.com:microsoft/agent-host-protocol.git ../agent-host-protocol');
process.exit(1);
}
const commitHash = getSourceCommitHash();
console.log(`Syncing from agent-host-protocol @ ${commitHash}`);
console.log(` Source: ${TYPES_DIR}`);
console.log(` Dest: ${DEST_DIR}`);
console.log();
// Discover and copy protocol files
const files = discoverSourceFiles();
for (const file of files) {
const srcPath = path.join(TYPES_DIR, file.src);
processFile(srcPath, file.dest);
}
// Write the source commit hash to a single version file
const versionFile = path.join(DEST_DIR, '.ahp-version');
fs.writeFileSync(versionFile, commitHash + '\n', 'utf-8');
console.log(` .ahp-version -> ${commitHash}`);
console.log();
console.log('Done.');
}
main();