Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { registerView } from './commands/view';
import { COMMAND_DESCRIPTIONS, PACKAGE_VERSION } from './constants';
import { printPostCommandNotices, printTelemetryNotice } from './notices';
import { ALL_PRIMITIVES } from './primitives';
import { TelemetryClientAccessor } from './telemetry';
import { TelemetryClientAccessor, registerLegacyProjectMigrationReporter } from './telemetry';
import { renderTUI, setupAltScreenCleanup } from './tui';
import { LayoutProvider } from './tui/context';
import { clearExitMessage, getExitMessage } from './tui/exit-message';
Expand Down Expand Up @@ -140,6 +140,10 @@ export const main = async (argv: string[]) => {
// Register global cleanup handlers once at startup
setupAltScreenCleanup();

// Observe (telemetry + one-time deprecation notice) when a pre-v0.4.0 agentcore.json
// is auto-migrated on read. Registered once before any command/TUI loads a project.
registerLegacyProjectMigrationReporter();

// Generate installationId on first run and show telemetry notice. If we
// could not persist the id, suppress the notice so it doesn't fire every run.
const installationIdResult = await getOrCreateInstallationId();
Expand Down
4 changes: 4 additions & 0 deletions src/cli/notices.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ANSI } from './constants';
import { resolveTelemetryPreference } from './telemetry/config';
import { printLegacyProjectMigrationNotice } from './telemetry/legacy-project-migration';
import { type UpdateCheckResult, printUpdateNotification } from './update-notifier';

export async function printTelemetryNotice(): Promise<void> {
Expand Down Expand Up @@ -28,6 +29,9 @@ export async function printPostCommandNotices(
if (isFirstRun) {
await printTelemetryNotice();
}
// Flush the pre-v0.4.0 deprecation notice (if a legacy agentcore.json was migrated this run) only
// now that any TUI alt-screen buffer has been restored, so the notice is actually visible.
printLegacyProjectMigrationNotice();
const result = await updateCheck;
if (result?.updateAvailable) {
printUpdateNotification(result);
Expand Down
104 changes: 104 additions & 0 deletions src/cli/telemetry/__tests__/legacy-project-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { LegacyProjectMigrationInfo, LegacyProjectMigrationReporter } from '../../../lib/schemas/io/config-io';
import * as configIo from '../../../lib/schemas/io/config-io';
import { TelemetryClient } from '../client';
import { TelemetryClientAccessor } from '../client-accessor';
import {
printLegacyProjectMigrationNotice,
registerLegacyProjectMigrationReporter,
resetLegacyProjectMigrationNotice,
} from '../legacy-project-migration';
import { InMemorySink } from '../sinks/in-memory-sink';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

let sink: InMemorySink;
let capturedReporter: LegacyProjectMigrationReporter | undefined;

const ALL_LEGACY: LegacyProjectMigrationInfo = {
hadAgentsKey: true,
hadCredentialTypeKey: true,
hadRuntimeTypeKey: true,
};

/** Wait for the reporter's fire-and-forget telemetry promise chain to settle. */
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}

beforeEach(() => {
sink = new InMemorySink();
capturedReporter = undefined;
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));
// Capture the reporter the CLI installs, instead of letting it mutate lib module state.
vi.spyOn(configIo, 'setLegacyProjectMigrationReporter').mockImplementation(reporter => {
capturedReporter = reporter;
});
resetLegacyProjectMigrationNotice();
});

afterEach(() => {
vi.restoreAllMocks();
resetLegacyProjectMigrationNotice();
});

describe('registerLegacyProjectMigrationReporter', () => {
it('emits cli.legacy_project_migrated with the camelCase->snake_case attribute mapping', async () => {
registerLegacyProjectMigrationReporter();
expect(capturedReporter).toBeDefined();

capturedReporter!({ hadAgentsKey: true, hadCredentialTypeKey: false, hadRuntimeTypeKey: true });
await flushMicrotasks();

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.metric).toBe('cli.legacy_project_migrated');
expect(sink.metrics[0]!.value).toBe(1);
// Booleans are serialized to strings by TelemetryClient.emit; assert each key maps to its own field.
expect(sink.metrics[0]!.attrs).toEqual({
had_agents_key: 'true',
had_credential_type_key: 'false',
had_runtime_type_key: 'true',
});
});

it('does not let a TelemetryClientAccessor.get() rejection propagate', async () => {
vi.spyOn(TelemetryClientAccessor, 'get').mockRejectedValue(new Error('no client'));
registerLegacyProjectMigrationReporter();

expect(() => capturedReporter!(ALL_LEGACY)).not.toThrow();
await flushMicrotasks();
expect(sink.metrics).toHaveLength(0);
});
});

describe('printLegacyProjectMigrationNotice', () => {
it('is a no-op when no migration was observed', () => {
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
printLegacyProjectMigrationNotice();
expect(write).not.toHaveBeenCalled();
});

it('prints once after a migration is observed, then is a no-op (one-time latch)', async () => {
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true);

registerLegacyProjectMigrationReporter();
capturedReporter!(ALL_LEGACY);
await flushMicrotasks();

printLegacyProjectMigrationNotice();
printLegacyProjectMigrationNotice();

expect(write).toHaveBeenCalledTimes(1);
expect(String(write.mock.calls[0]![0])).toContain('pre-v0.4.0');
});

it('does not print synchronously from the reporter (deferred to keep it out of the TUI alt-screen)', async () => {
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true);

registerLegacyProjectMigrationReporter();
capturedReporter!(ALL_LEGACY);
await flushMicrotasks();

// The reporter only arms the notice; nothing is written until printPostCommandNotices flushes it.
expect(write).not.toHaveBeenCalled();
});
});
5 changes: 5 additions & 0 deletions src/cli/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ export { TelemetryClient } from './client.js';
export { type MetricSink } from './sinks/metric-sink.js';
export { OtelMetricSink, type OtelMetricSinkConfig } from './sinks/otel-metric-sink.js';
export { FileSystemSink, type FileSystemSinkConfig } from './sinks/filesystem-sink.js';
export {
registerLegacyProjectMigrationReporter,
printLegacyProjectMigrationNotice,
resetLegacyProjectMigrationNotice,
} from './legacy-project-migration.js';
66 changes: 66 additions & 0 deletions src/cli/telemetry/legacy-project-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { LegacyProjectMigrationInfo } from '../../lib/schemas/io/config-io.js';
import { setLegacyProjectMigrationReporter } from '../../lib/schemas/io/config-io.js';
import { ANSI } from '../constants.js';
import { TelemetryClientAccessor } from './client-accessor.js';

/**
* Set once a pre-v0.4.0 agentcore.json is auto-migrated on read, so the deprecation notice can be
* printed *after* the command/TUI exits. Printing synchronously from the reporter would land the
* notice inside the Ink alt-screen buffer (most legacy-project reads happen inside a TUI flow) where
* it is immediately repainted over and lost — so we defer, mirroring `printTelemetryNotice` /
* `printUpdateNotification` which are also flushed via `printPostCommandNotices`.
*/
let migrationObserved = false;
let noticePrinted = false;

/** Reset the deferred-notice state. Test-only. */
export function resetLegacyProjectMigrationNotice(): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This new CLI-layer module has no direct unit test. The lib-layer tests in config-io.test.ts verify that the reporter is invoked with the right LegacyProjectMigrationInfo, but nothing exercises what this file actually does with that info, specifically:

  • The camelCase → snake_case attribute mapping (hadAgentsKeyhad_agents_key, etc.) on the cli.legacy_project_migrated emit. TypeScript catches a missing/renamed attribute, but it won't catch a value going to the wrong attribute (e.g. had_agents_key: info.hadCredentialTypeKey). A one-line assertion against InMemorySink would lock this down.
  • The "one-time" semantics of printDeprecationNotice — that the second invocation of the reporter is a no-op for the notice. resetLegacyProjectMigrationNotice is exported as test-only specifically for this, but is currently unused (dead export).
  • That a TelemetryClientAccessor.get() rejection doesn't propagate (the .catch(() => {}) contract).

The existing src/cli/telemetry/__tests__/client.test.ts is a good template — it spies TelemetryClientAccessor.get to return a TelemetryClient(InMemorySink) and asserts on sink.metrics. A few similar cases here would (a) actually use the resetLegacyProjectMigrationNotice export and (b) prevent silent regressions in the attribute mapping / one-time latch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added src/cli/telemetry/__tests__/legacy-project-migration.test.ts covering exactly these gaps, using the TelemetryClientAccessor.get + InMemorySink template from client.test.ts:

  • Attribute mapping — asserts cli.legacy_project_migrated is emitted with { had_agents_key, had_credential_type_key, had_runtime_type_key } each carrying its own value (e.g. hadCredentialTypeKey: false maps to had_credential_type_key: 'false'), which would catch a wrong-attribute wiring bug.
  • One-time latch — invokes the reporter, then calls printLegacyProjectMigrationNotice() twice and asserts process.stderr.write fires exactly once. This now uses the resetLegacyProjectMigrationNotice export in beforeEach/afterEach (no longer a dead export).
  • Deferred-print contract — asserts the reporter does not write to stderr synchronously (the notice is only flushed later).
  • Rejection swallowing — mocks TelemetryClientAccessor.get() to reject and asserts the reporter doesn't throw and no metric is recorded.

migrationObserved = false;
noticePrinted = false;
}

/**
* Print the one-time pre-v0.4.0 deprecation notice if a legacy agentcore.json was migrated during
* this invocation. No-op if no migration was observed or the notice already fired. Call after the
* TUI/command finishes (the alt-screen buffer has been restored) — see `printPostCommandNotices`.
*/
export function printLegacyProjectMigrationNotice(): void {
if (!migrationObserved || noticePrinted) return;
noticePrinted = true;
const { yellow, reset } = ANSI;
process.stderr.write(
[
'',
`${yellow}Your agentcore.json uses pre-v0.4.0 keys (\`agents\`, and/or \`type\` on`,
'credentials/runtimes). These are auto-migrated for now, but support will be',
'removed in a future release. Update the file to use `runtimes` and',
`\`authorizerType\` to silence this notice.${reset}`,
'',
].join('\n')
);
}

/**
* Wire the CLI's observability into the lib config loader: when a pre-v0.4.0 agentcore.json is
* auto-migrated on read, emit the `cli.legacy_project_migrated` metric (so legacy-project adoption
* is measurable and the shim can eventually be removed) and arm a one-time deprecation notice that
* `printPostCommandNotices` flushes after the alt-screen buffer is restored.
*
* Kept here in the CLI layer so `src/lib` stays free of any telemetry/CLI import.
*/
export function registerLegacyProjectMigrationReporter(): void {
setLegacyProjectMigrationReporter((info: LegacyProjectMigrationInfo) => {
migrationObserved = true;
void TelemetryClientAccessor.get()
.then(client =>
client.emit('cli.legacy_project_migrated', 1, {
had_agents_key: info.hadAgentsKey,
had_credential_type_key: info.hadCredentialTypeKey,
had_runtime_type_key: info.hadRuntimeTypeKey,
})
)
.catch(() => {
// Telemetry is best-effort and must never affect CLI behavior.
});
});
}
3 changes: 3 additions & 0 deletions src/cli/telemetry/schemas/common-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,7 @@ export const ATTRIBUTES = {
skill_source_type: SkillSourceType,
ui_mode: UiMode,
policy_validation_mode: PolicyValidationMode,
had_agents_key: z.boolean(),
had_credential_type_key: z.boolean(),
had_runtime_type_key: z.boolean(),
} as const;
15 changes: 14 additions & 1 deletion src/cli/telemetry/schemas/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,20 @@ export const METRICS = {
'cli.command_run': {
description: 'CLI/TUI Command Execution',
},
'cli.legacy_project_migrated': {
description: 'A pre-v0.4.0 agentcore.json was auto-migrated to current schema keys on read',
},
} as const satisfies MetricRegistry;

interface LegacyProjectMigratedAttrs {
had_agents_key: z.infer<typeof ATTRIBUTES.had_agents_key>;
had_credential_type_key: z.infer<typeof ATTRIBUTES.had_credential_type_key>;
had_runtime_type_key: z.infer<typeof ATTRIBUTES.had_runtime_type_key>;
}

export type MetricName = keyof typeof METRICS;
export type MetricAttrs<M extends MetricName> = M extends 'cli.command_run' ? CommandRunAttrs : never;
export type MetricAttrs<M extends MetricName> = M extends 'cli.command_run'
? CommandRunAttrs
: M extends 'cli.legacy_project_migrated'
? LegacyProjectMigratedAttrs
: never;
96 changes: 95 additions & 1 deletion src/lib/schemas/io/__tests__/config-io.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NoProjectError } from '../../../errors';
import { ConfigNotFoundError, ConfigParseError, ConfigValidationError } from '../../../errors/types.js';
import { ConfigIO } from '../config-io.js';
import { ConfigIO, type LegacyProjectMigrationInfo, setLegacyProjectMigrationReporter } from '../config-io.js';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { mkdir, rm, unlink, writeFile } from 'node:fs/promises';
Expand Down Expand Up @@ -160,6 +160,100 @@ describe('ConfigIO', () => {
});
});

describe('readProjectSpec legacy migration reporter', () => {
afterEach(() => {
setLegacyProjectMigrationReporter(undefined);
});

function writeProjectFixture(spec: unknown): ConfigIO {
const projectDir = join(testDir, `legacy-migrate-${randomUUID()}`);
const agentcoreDir = join(projectDir, 'agentcore');
mkdirSync(agentcoreDir, { recursive: true });
writeFileSync(join(agentcoreDir, 'agentcore.json'), JSON.stringify(spec));
changeWorkingDir(projectDir);
return new ConfigIO();
}

it('fires the reporter with detected legacy keys for a pre-v0.4.0 project (issue #719)', async () => {
const captured: LegacyProjectMigrationInfo[] = [];
setLegacyProjectMigrationReporter(info => captured.push(info));

const configIO = writeProjectFixture({
name: 'TestProject',
version: 1,
agents: [
{
type: 'AgentCoreRuntime',
name: 'MyAgent',
build: 'CodeZip',
entrypoint: 'main.py',
codeLocation: 'app/MyAgent/',
runtimeVersion: 'PYTHON_3_12',
protocol: 'HTTP',
},
],
credentials: [
{
type: 'OAuthCredentialProvider',
name: 'my-oauth',
discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration',
},
],
});

const spec = await configIO.readProjectSpec();

expect(spec.runtimes[0]!.name).toBe('MyAgent');
expect(spec.credentials[0]!.authorizerType).toBe('OAuthCredentialProvider');
expect(captured).toHaveLength(1);
expect(captured[0]).toEqual({
hadAgentsKey: true,
hadCredentialTypeKey: true,
hadRuntimeTypeKey: true,
});
});

it('does not fire the reporter for a current-shape project', async () => {
const captured: LegacyProjectMigrationInfo[] = [];
setLegacyProjectMigrationReporter(info => captured.push(info));

const configIO = writeProjectFixture({
name: 'TestProject',
version: 1,
runtimes: [
{
name: 'MyAgent',
build: 'CodeZip',
entrypoint: 'main.py',
codeLocation: 'app/MyAgent/',
runtimeVersion: 'PYTHON_3_12',
protocol: 'HTTP',
},
],
credentials: [{ authorizerType: 'ApiKeyCredentialProvider', name: 'MyCred' }],
});

await configIO.readProjectSpec();
expect(captured).toHaveLength(0);
});

it('a throwing reporter never breaks the read', async () => {
setLegacyProjectMigrationReporter(() => {
throw new Error('reporter boom');
});

const configIO = writeProjectFixture({
name: 'TestProject',
version: 1,
agents: [],
credentials: [{ type: 'ApiKeyCredentialProvider', name: 'MyCred' }],
});

const spec = await configIO.readProjectSpec();
expect(spec.credentials[0]!.authorizerType).toBe('ApiKeyCredentialProvider');
});
});

describe('writeProjectSpec', () => {
it('throws ConfigValidationError for invalid project data', async () => {
const projectDir = join(testDir, `invalid-write-${randomUUID()}`);
Expand Down
Loading
Loading