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
118 changes: 63 additions & 55 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
"react": "^19.2.7",
"react-devtools-core": "^7.0.1",
"react-router": "^8.1.0",
"pino": "^10.3.1",
"pino-roll": "^4.0.0",
"zod": "^4.4.3"
}
}
7 changes: 5 additions & 2 deletions src/handlers/config/config.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect, describe } from "bun:test";
import { createRootHandler } from "../index";
import { TestCoreClient, testIO } from "../../testing";
import { createSilentLogger, TestCoreClient, testIO } from "../../testing";

// End-to-end tests for the `config` command, driven through the real root
// handler and top-level route(). A TestCoreClient stands in for Core (config
Expand All @@ -9,7 +9,10 @@ import { TestCoreClient, testIO } from "../../testing";

async function run(args: string[]): Promise<string> {
const io = testIO();
const root = createRootHandler(new TestCoreClient(), io.io);
const root = createRootHandler(new TestCoreClient(), {
io: io.io,
logger: createSilentLogger(),
});
await root.route(["node", "agentcore", "config", ...args]);
return io.stdout();
}
Expand Down
10 changes: 8 additions & 2 deletions src/handlers/harness/harness.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { test, expect, describe } from "bun:test";
import { join } from "node:path";
import { CoreClient } from "../../core";
import { createRootHandler } from "../index";
import { fixtureFactories, isRecording, matchGolden, testIO } from "../../testing";
import {
createSilentLogger,
fixtureFactories,
isRecording,
matchGolden,
testIO,
} from "../../testing";

// End-to-end command-flow tests for the `harness` subtree.
//
Expand All @@ -26,7 +32,7 @@ async function run(args: string[]): Promise<string> {
const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES);
const core = new CoreClient(createControlClient, createDataClient, createIamClient);
const io = testIO();
const root = createRootHandler(core, io.io);
const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() });
await root.route(["node", "agentcore", ...args, "--region", REGION]);
return io.stdout();
}
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/harness/invoke/invoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
} from "@aws-sdk/client-bedrock-agentcore";
import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control";
import { createRootHandler } from "../../index";
import { TestCoreClient, testIO } from "../../../testing";
import { createSilentLogger, TestCoreClient, testIO } from "../../../testing";

// Command-flow tests for `harness invoke`, driven through the real root handler
// exactly as the CLI runs it. Unlike the get/list suites these use a
Expand Down Expand Up @@ -41,7 +41,7 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void) {
core.harness.setInvokeEvents(...TURN_EVENTS);
configure?.(core);
const io = testIO();
const root = createRootHandler(core, io.io);
const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() });
await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]);
return { core, stdout: io.stdout() };
}
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/help.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { render, cleanup } from "ink-testing-library";
import { ValueContext, compile, CommandKey } from "../router";
import { createRootHandler } from "./index";
import { HelpScreen } from "./screen";
import { TestCoreClient, testIO } from "../testing";
import { createSilentLogger, TestCoreClient, testIO } from "../testing";

afterEach(cleanup);

Expand All @@ -16,7 +16,7 @@ afterEach(cleanup);
describe("HelpScreen", () => {
test("renders the command's help text", () => {
const command = compile(
createRootHandler(new TestCoreClient(), testIO().io),
createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger() }),
ValueContext.EmptyContext(),
);
const ctx = ValueContext.EmptyContext().withValue(CommandKey, command);
Expand Down
14 changes: 12 additions & 2 deletions src/handlers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import { createHarnessHandler } from "./harness/index.tsx";
import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx";
import { createConfigHandler } from "./config/";
import { renderTui } from "../tui";
import { withRegion, withJsonRenderer } from "../middleware";
import { withRegion, withJsonRenderer, withLogging } from "../middleware";
import type { AppIO, Core } from "./types.tsx";
import type { Logger } from "../logging";

export function createRootHandler(core: Core, io: AppIO): Router {
export interface RootHandlerConfig {
io: AppIO;
logger: Logger;
}

export function createRootHandler(core: Core, config: RootHandlerConfig): Router {
const { io, logger } = config;
const root = new Router("agentcore", "the platform for production AI agents");

// Add global flags
Expand All @@ -20,6 +27,9 @@ export function createRootHandler(core: Core, io: AppIO): Router {
// machine-readable output without touching the process streams directly.
root.use(withJsonRenderer(io));

// Inject a logger into each handler.
root.use(withLogging({ logger }));

// Install sub handlers
root.handler(createHarnessHandler(core, io));
root.handler(createConfigHandler(io));
Expand Down
7 changes: 5 additions & 2 deletions src/handlers/root.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { test, expect, describe } from "bun:test";
import { createRootHandler } from "./index";
import { TestCoreClient, testIO } from "../testing";
import { createSilentLogger, TestCoreClient, testIO } from "../testing";

describe("createRootHandler", () => {
test("builds the agentcore command tree with its subcommands", () => {
const root = createRootHandler(new TestCoreClient(), testIO().io);
const root = createRootHandler(new TestCoreClient(), {
io: testIO().io,
logger: createSilentLogger(),
});
expect(root.name()).toBe("agentcore");
expect(root.children().map((c) => c.name())).toEqual(["harness", "config"]);
});
Expand Down
55 changes: 43 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,59 @@
// published `bin` directly executable by Node. It's ignored during development
// when the file is run via `bun run src/index.ts`.

import { homedir } from "os";
import { join } from "path";

import { CoreClient } from "./core";
import { createControlClient, createDataClient, createIamClient } from "./core/factories";
import { createRootHandler } from "./handlers";
import { createFileLogger, LOG_LEVEL } from "./logging";
import { runWithExitCode } from "./runnable";

process.exit(
await runWithExitCode(async (argv: string[]) => {
// Wrap the SDK clients in the CoreClient the handlers consume. Passing
// factories (rather than instances) lets CoreClient build one client per
// region on demand.
const coreClient = new CoreClient(createControlClient, createDataClient, createIamClient);

// Pass it to the root handler, along with the process's standard streams as
// the app's io. CoreClient exposes feature sub-clients (e.g. `.harness`), so
// it satisfies the Core contract directly.
const rootHandler = createRootHandler(coreClient, {
// generate a unique identifier corresponding to this process of this CLI. (ex. one command invoke, one TUI session)
// TODO: wire this id into telemetry as well
const cliSessionId = crypto.randomUUID();

const rootLogger = createFileLogger({
filePath: join(homedir(), ".agentcore", "logs", "output"),
// TODO: allow overriding via global settings
logLevel: LOG_LEVEL.DEBUG,
bindings: { cliSessionId },
});

const io = {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});
};

try {
// Wrap the SDK clients in the CoreClient the handlers consume. Passing
// factories (rather than instances) lets CoreClient build one client per
// region on demand.
const coreClient = new CoreClient(createControlClient, createDataClient, createIamClient);

// Pass it to the root handler, along with the process's standard streams as
// the app's io. CoreClient exposes feature sub-clients (e.g. `.harness`), so
// it satisfies the Core contract directly.
const rootHandler = createRootHandler(coreClient, {
io,
logger: rootLogger,
});

// Handle the request
await rootHandler.route(argv);
// Handle the request
await rootHandler.route(argv);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
io.stderr.write(`${error.name}: ${error.message}\n`);
rootLogger
.child({ errorName: error.name, errorMessage: error.message, stack: error.stack ?? "" })
.error();
throw e;
} finally {
await rootLogger.flush();
}
}),
);
62 changes: 62 additions & 0 deletions src/logging/fileLogger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pino from "pino";
import { type AsyncLogger, type LoggerBindings, type LogLevel } from "./types";

export interface FileLoggerConfig {
filePath: string;
maxSizeInMB?: number;
maxFileCount?: number;
bindings?: LoggerBindings;
logLevel: LogLevel;
}

function wrapPinoLogger(pinoLogger: pino.Logger): AsyncLogger {
const log =
(level: pino.Level) =>
(...args: string[]) =>
pinoLogger[level](args.join(" "));
return {
debug: log("debug"),
info: log("info"),
warn: log("warn"),
error: log("error"),
child: (bindings) => wrapPinoLogger(pinoLogger.child(bindings)),
// we convert pino's flush method that accepts a callback into a promise to make it easier to work with.
// Note: we also treat flush as best-effort and swallow errors
flush: () => new Promise<void>((resolve) => pinoLogger.flush(() => resolve())),
};
}

/**
* Creates a logger that writes structured JSON to a rotating file.
*
* @param config - Logger configuration (file path, rotation limits, level).
* @returns A {@link AsyncLogger} that writes to a rotating file via pino.
*/
export function createFileLogger(config: FileLoggerConfig): AsyncLogger {
const maxSizeInMB = config.maxSizeInMB ?? 10;
const maxFileCount = config.maxFileCount ?? 5;
const bindings = config.bindings ?? {};
return wrapPinoLogger(
pino({
level: config.logLevel,
base: undefined, // omit pid and hostname
formatters: {
level(label) {
return { level: label };
},
},
transport: {
target: "pino-roll",
options: {
extension: ".log",
dateFormat: "yyyy-MM-dd'T'HH-mm-ss",
// Rotate when file reaches {maxSizeInMB} MB, and start deleting once we have {maxFileCount} files
size: `${maxSizeInMB}m`,
limit: { count: maxFileCount },
file: config.filePath,
mkdir: true,
},
},
}),
).child(bindings);
}
2 changes: 2 additions & 0 deletions src/logging/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { type Logger, type LoggerBindings, LOG_LEVEL } from "./types";
export { createFileLogger } from "./fileLogger";
31 changes: 31 additions & 0 deletions src/logging/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Available log levels ordered by severity. `SILENT` disables all output.
*/
export const LOG_LEVEL = {
DEBUG: "debug",
INFO: "info",
WARN: "warn",
ERROR: "error",
SILENT: "silent",
} as const;

export type LogLevel = (typeof LOG_LEVEL)[keyof typeof LOG_LEVEL];

export type LoggerBindings = Record<string, unknown>;

type LogFn = (...messages: string[]) => void;

/** App-wide structured logging contract with child-logger support */
export interface Logger {
debug: LogFn;
info: LogFn;
warn: LogFn;
error: LogFn;
child: (bindings: LoggerBindings) => Logger;
}

/** An extension of {@link Logger} that writes logs asynchronously and requires output to be flushed */
export interface AsyncLogger extends Logger {
child: (bindings: LoggerBindings) => AsyncLogger;
flush: () => Promise<void>;
}
1 change: 1 addition & 0 deletions src/middleware/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { withRegion } from "./withRegion";
export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs";
export { withJsonRenderer } from "./withJsonRenderer";
export { withLogging } from "./withLogging";
69 changes: 69 additions & 0 deletions src/middleware/withLogging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { test, describe, beforeEach, afterEach } from "bun:test";
import { Router, createHandler } from "../router";
import { withLogging } from "./withLogging";
import { createFileLogger } from "../logging/fileLogger";
import { LOG_LEVEL, type AsyncLogger } from "../logging/types";
import { assertLogsMatch } from "../testing";
import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

describe("withLogging", () => {
let tempDir: string;
let logger: AsyncLogger;

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "logging-test-"));
logger = createFileLogger({
filePath: join(tempDir, "output"),
logLevel: LOG_LEVEL.DEBUG,
});
});

afterEach(async () => {
await logger.flush();
await rm(tempDir, { recursive: true, force: true });
});

test("logs success and error with correct command path bindings", async () => {
const app = new Router("myapp", "test app");
app.use(withLogging({ logger }));
app.handler(
createHandler({
name: "happy",
description: "succeeds",
handle: async () => {},
}),
);
app.handler(
createHandler({
name: "boom",
description: "throws",
handle: async () => {
throw new Error("connection timeout");
},
}),
);

await app.route(["node", "myapp", "happy"]);
await app.route(["node", "myapp", "boom"]).catch(() => {});
await app.route(["node", "myapp", "happy"]);

await assertLogsMatch(tempDir, [
{
filter: (l: any) =>
l.msg === "command executed successfully" && l.commandPath === "/myapp/happy",
expectedCount: 2,
},
{
filter: (l: any) =>
l.level === "error" &&
l.msg === "command failed" &&
l.errorName === "Error" &&
l.errorMessage === "connection timeout" &&
l.commandPath === "/myapp/boom",
expectedCount: 1,
},
]);
});
});
Loading