feat(fleet): add reviewed v1-to-v2 migration preview (#788)
This commit was merged in pull request #788.
This commit is contained in:
280
packages/mosaic/src/commands/fleet-migration-command.spec.ts
Normal file
280
packages/mosaic/src/commands/fleet-migration-command.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerFleetMigrationCommand } from './fleet-migration-command.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
describe('fleet migrate-v1 preview command', (): void => {
|
||||
it('emits one ready JSON result without any runtime or file mutation API', async () => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-'));
|
||||
const rolesDir = join(cleanup, 'roles');
|
||||
const overrideDir = join(cleanup, 'roles.local');
|
||||
await mkdir(rolesDir);
|
||||
await mkdir(overrideDir);
|
||||
await writeFile(join(rolesDir, 'code.md'), '# code\n');
|
||||
const files: Record<string, string> = {
|
||||
source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`,
|
||||
decisions: JSON.stringify({
|
||||
generation: 2,
|
||||
defaultRuntime: 'pi',
|
||||
agents: {
|
||||
coder0: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
enabled: true,
|
||||
launchYolo: false,
|
||||
toolPolicyDisposition: { action: 'replace', className: 'code' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }),
|
||||
};
|
||||
const printJson = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
const program = new Command();
|
||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', cleanup);
|
||||
registerFleetMigrationCommand(fleet, {
|
||||
mosaicHome: cleanup,
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
readText: async (path): Promise<string> => files[path] ?? '',
|
||||
printJson,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'fleet',
|
||||
'migrate-v1',
|
||||
'preview',
|
||||
'--source',
|
||||
'source',
|
||||
'--decisions',
|
||||
'decisions',
|
||||
'--observations',
|
||||
'observations',
|
||||
]);
|
||||
|
||||
expect(printJson).toHaveBeenCalledTimes(1);
|
||||
expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' }));
|
||||
expect(setExitCode).not.toHaveBeenCalled();
|
||||
expect(fleet.helpInformation()).toContain('migrate-v1');
|
||||
const migration = fleet.commands.find((command) => command.name() === 'migrate-v1');
|
||||
expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/);
|
||||
});
|
||||
|
||||
it('emits one stable blocked JSON result when --observations is missing', async () => {
|
||||
const printJson = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
const program = new Command().exitOverride();
|
||||
program.configureOutput({
|
||||
writeErr: vi.fn(),
|
||||
writeOut: vi.fn(),
|
||||
});
|
||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
||||
registerFleetMigrationCommand(fleet, {
|
||||
mosaicHome: '/unused',
|
||||
readText: vi.fn(),
|
||||
printJson,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'fleet',
|
||||
'migrate-v1',
|
||||
'preview',
|
||||
'--source',
|
||||
'source',
|
||||
'--decisions',
|
||||
'decisions',
|
||||
]),
|
||||
).resolves.toBe(program);
|
||||
expect(printJson).toHaveBeenCalledTimes(1);
|
||||
expect(printJson).toHaveBeenCalledWith({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'missing-migration-preview-option',
|
||||
path: 'request.observations',
|
||||
detail: 'Required migration preview option is missing.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it.each(['source', 'decisions', 'observations'] as const)(
|
||||
'emits one stable blocked JSON result when bare --%s has no value',
|
||||
async (bareOption) => {
|
||||
const printJson = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
const readText = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
const program = new Command().exitOverride();
|
||||
program.configureOutput({ writeErr, writeOut: vi.fn() });
|
||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
||||
registerFleetMigrationCommand(fleet, {
|
||||
mosaicHome: '/unused',
|
||||
readText,
|
||||
printJson,
|
||||
setExitCode,
|
||||
});
|
||||
const args = [
|
||||
'node',
|
||||
'test',
|
||||
'fleet',
|
||||
'migrate-v1',
|
||||
'preview',
|
||||
'--source=source',
|
||||
'--decisions=decisions',
|
||||
'--observations=observations',
|
||||
];
|
||||
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
|
||||
`--${bareOption}`;
|
||||
|
||||
await expect(program.parseAsync(args)).resolves.toBe(program);
|
||||
|
||||
expect(printJson).toHaveBeenCalledTimes(1);
|
||||
expect(printJson).toHaveBeenCalledWith({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'missing-migration-preview-option-value',
|
||||
path: `request.${bareOption}`,
|
||||
detail: 'Required migration preview option value is missing.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledTimes(1);
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
expect(readText).not.toHaveBeenCalled();
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['source', 'decisions', 'observations'] as const)(
|
||||
'emits one stable blocked JSON result when --%s is present-empty',
|
||||
async (emptyOption) => {
|
||||
const printJson = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
const readText = vi.fn();
|
||||
const program = new Command().exitOverride();
|
||||
program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() });
|
||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
||||
registerFleetMigrationCommand(fleet, {
|
||||
mosaicHome: '/unused',
|
||||
readText,
|
||||
printJson,
|
||||
setExitCode,
|
||||
});
|
||||
const paths = { source: 'source', decisions: 'decisions', observations: 'observations' };
|
||||
paths[emptyOption] = '';
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'fleet',
|
||||
'migrate-v1',
|
||||
'preview',
|
||||
`--source=${paths.source}`,
|
||||
`--decisions=${paths.decisions}`,
|
||||
`--observations=${paths.observations}`,
|
||||
]),
|
||||
).resolves.toBe(program);
|
||||
|
||||
expect(printJson).toHaveBeenCalledTimes(1);
|
||||
expect(printJson).toHaveBeenCalledWith({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'empty-migration-preview-option',
|
||||
path: `request.${emptyOption}`,
|
||||
detail: 'Required migration preview option must be a non-empty path.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledTimes(1);
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
expect(readText).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('emits a blocked result and sets exit 1 for malformed evidence', async () => {
|
||||
const printJson = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
const program = new Command();
|
||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
||||
registerFleetMigrationCommand(fleet, {
|
||||
mosaicHome: '/unused',
|
||||
readText: async (path): Promise<string> => (path === 'decisions' ? 'not-json' : '{}'),
|
||||
printJson,
|
||||
setExitCode,
|
||||
});
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'fleet',
|
||||
'migrate-v1',
|
||||
'preview',
|
||||
'--source',
|
||||
'source',
|
||||
'--decisions',
|
||||
'decisions',
|
||||
'--observations',
|
||||
'observations',
|
||||
]);
|
||||
expect(printJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'blocked',
|
||||
blockers: [expect.objectContaining({ code: 'migration-preview-failed' })],
|
||||
}),
|
||||
);
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('redacts adversarial values from validation failures', async () => {
|
||||
const secret = 'never-print-command-or-token';
|
||||
const printJson = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
const program = new Command();
|
||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
||||
registerFleetMigrationCommand(fleet, {
|
||||
mosaicHome: '/unused',
|
||||
readText: async (path): Promise<string> =>
|
||||
path === 'decisions'
|
||||
? JSON.stringify({ generation: 2, agents: {}, [secret]: secret })
|
||||
: '{}',
|
||||
printJson,
|
||||
setExitCode,
|
||||
});
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'fleet',
|
||||
'migrate-v1',
|
||||
'preview',
|
||||
'--source',
|
||||
'source',
|
||||
'--decisions',
|
||||
'decisions',
|
||||
'--observations',
|
||||
'observations',
|
||||
]);
|
||||
expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret);
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
170
packages/mosaic/src/commands/fleet-migration-command.ts
Normal file
170
packages/mosaic/src/commands/fleet-migration-command.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
parseV1MigrationObservations,
|
||||
parseV1ToV2MigrationDecisions,
|
||||
previewV1ToV2Migration,
|
||||
} from '../fleet/v1-v2-migration.js';
|
||||
|
||||
export interface FleetMigrationCommandDeps {
|
||||
readonly mosaicHome?: string;
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
readonly readText?: (path: string) => Promise<string>;
|
||||
readonly printJson?: (value: unknown) => void;
|
||||
readonly setExitCode?: (code: number) => void;
|
||||
}
|
||||
|
||||
interface PreviewOptions {
|
||||
readonly source?: string | boolean;
|
||||
readonly decisions?: string | boolean;
|
||||
readonly observations?: string | boolean;
|
||||
}
|
||||
|
||||
/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */
|
||||
export function registerFleetMigrationCommand(
|
||||
fleetCommand: Command,
|
||||
deps: FleetMigrationCommandDeps = {},
|
||||
): void {
|
||||
fleetCommand
|
||||
.command('migrate-v1')
|
||||
.description('Preview a field-complete v1-to-v2 roster migration')
|
||||
.command('preview')
|
||||
.description('Compile migration evidence without writing files or changing runtimes')
|
||||
.option('--source [path]', 'v1 roster YAML or JSON')
|
||||
.option('--decisions [path]', 'explicit migration decisions JSON')
|
||||
.option('--observations [path]', 'reviewed lifecycle observations JSON')
|
||||
.action(async (options: PreviewOptions): Promise<void> => {
|
||||
const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8'));
|
||||
const printJson =
|
||||
deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value)));
|
||||
const setExitCode =
|
||||
deps.setExitCode ?? ((code: number): void => void (process.exitCode = code));
|
||||
try {
|
||||
const requiredOptionNames = ['source', 'decisions', 'observations'] as const;
|
||||
const missingOption = requiredOptionNames.find((name) => options[name] === undefined);
|
||||
if (missingOption !== undefined) {
|
||||
printJson({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'missing-migration-preview-option',
|
||||
path: `request.${missingOption}`,
|
||||
detail: 'Required migration preview option is missing.',
|
||||
},
|
||||
],
|
||||
});
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const missingValueOption = requiredOptionNames.find((name) => options[name] === true);
|
||||
if (missingValueOption !== undefined) {
|
||||
printJson({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'missing-migration-preview-option-value',
|
||||
path: `request.${missingValueOption}`,
|
||||
detail: 'Required migration preview option value is missing.',
|
||||
},
|
||||
],
|
||||
});
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const emptyOption = requiredOptionNames.find(
|
||||
(name) => typeof options[name] === 'string' && options[name].trim() === '',
|
||||
);
|
||||
if (emptyOption !== undefined) {
|
||||
printJson({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'empty-migration-preview-option',
|
||||
path: `request.${emptyOption}`,
|
||||
detail: 'Required migration preview option must be a non-empty path.',
|
||||
},
|
||||
],
|
||||
});
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const sourcePath = options.source;
|
||||
const decisionsPath = options.decisions;
|
||||
const observationsPath = options.observations;
|
||||
if (
|
||||
typeof sourcePath !== 'string' ||
|
||||
typeof decisionsPath !== 'string' ||
|
||||
typeof observationsPath !== 'string'
|
||||
) {
|
||||
throw new Error('Validated migration preview options became unavailable.');
|
||||
}
|
||||
const [source, decisionsSource, observationsSource] = await Promise.all([
|
||||
readText(sourcePath),
|
||||
readText(decisionsPath),
|
||||
readText(observationsPath),
|
||||
]);
|
||||
const decisions = parseV1ToV2MigrationDecisions(
|
||||
parseJsonObject(decisionsSource, 'migration decisions'),
|
||||
);
|
||||
const observations = parseV1MigrationObservations(
|
||||
parseJsonObject(observationsSource, 'lifecycle observations'),
|
||||
);
|
||||
const mosaicHome =
|
||||
deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome;
|
||||
const preview = await previewV1ToV2Migration({
|
||||
source,
|
||||
sourcePath,
|
||||
decisions,
|
||||
observations,
|
||||
personaDirs: {
|
||||
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||
},
|
||||
environment: {
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
},
|
||||
});
|
||||
printJson(preview);
|
||||
if (preview.status === 'blocked') setExitCode(1);
|
||||
} catch (error: unknown) {
|
||||
printJson({
|
||||
status: 'blocked',
|
||||
blockers: [
|
||||
{
|
||||
code: 'migration-preview-failed',
|
||||
path: 'request',
|
||||
detail: safeErrorDetail(error),
|
||||
},
|
||||
],
|
||||
});
|
||||
setExitCode(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonObject(source: string, label: string): unknown {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source) as unknown;
|
||||
} catch {
|
||||
throw new Error(`${label} must be valid JSON.`);
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be a JSON object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeErrorDetail(error: unknown): string {
|
||||
if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message;
|
||||
return 'Migration preview failed without publishable detail.';
|
||||
}
|
||||
|
||||
function isPublishableValidationMessage(message: string): boolean {
|
||||
return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ describe('registerFleetCommand', () => {
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'migrate-v1',
|
||||
'persona',
|
||||
'plan',
|
||||
'profile',
|
||||
@@ -197,6 +198,26 @@ describe('fleet roster parsing', () => {
|
||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||
});
|
||||
|
||||
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'runtimes:',
|
||||
' pi: {}',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const loaded = await loadFleetRoster(rosterPath);
|
||||
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
|
||||
});
|
||||
|
||||
it('accepts optional agent alias and provider metadata without requiring them', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
@@ -270,6 +291,32 @@ describe('fleet roster parsing', () => {
|
||||
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
|
||||
});
|
||||
|
||||
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
|
||||
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
defaults: { working_directory: workingDirectory },
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
|
||||
expect.objectContaining({
|
||||
diagnostic: expect.objectContaining({
|
||||
code: 'unsafe-path',
|
||||
key: 'MOSAIC_AGENT_WORKDIR',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
} from './fleet-agent-crud-command.js';
|
||||
import {
|
||||
registerFleetMigrationCommand,
|
||||
type FleetMigrationCommandDeps,
|
||||
} from './fleet-migration-command.js';
|
||||
import {
|
||||
executeReconcilerCommandJson,
|
||||
registerFleetReconcilerCommands,
|
||||
@@ -97,6 +101,7 @@ export interface FleetCommandDeps {
|
||||
isStdinTTY?: boolean;
|
||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
@@ -480,7 +485,7 @@ function generateAgentEnvValues(
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
@@ -2048,6 +2053,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||
registerFleetAgentCrudCommands(cmd, deps);
|
||||
registerFleetMigrationCommand(cmd, {
|
||||
...deps.migrationDeps,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
});
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
@@ -2411,10 +2420,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
|
||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
|
||||
Reference in New Issue
Block a user