From d6ce447f49d47892f2af4e9775e63ba410ab211d Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 8 Jul 2026 09:53:39 -0400 Subject: [PATCH 1/3] Fleet node structured logging for capabilities and actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serveNode emits per-capability registration (debug) and per-invocation events — invoked/completed/failed, each with a duration and structured fields — through an injected FleetLogger whose shape matches @agent-relay/utils createLogger. A FleetLogger seam replaces the plain log/warn callbacks (which still work, adapted). `relay node up` gains --log-file, --log-level, and --log-json, which set the AGENT_RELAY_LOG_* env the shared logger reads and inject createLogger('fleet') into the served node. Without a flag the node stays quiet, surfacing only warnings, as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/cli/commands/core.ts | 11 +- packages/cli/src/cli/lib/broker-lifecycle.ts | 43 +++++++- packages/fleet/README.md | 26 +++++ packages/fleet/src/serve-node.test.ts | 106 +++++++++++++++++++ packages/fleet/src/serve-node.ts | 97 +++++++++++++++-- 5 files changed, 272 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 033b7e029..8f3e36bbe 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -270,6 +270,9 @@ export interface UpCommandOptions { stateDir?: string; brokerName?: string; config?: string; + logFile?: string; + logLevel?: string; + logJson?: boolean; } /** @@ -287,7 +290,13 @@ export function addUpCommandOptions(command: Command): Command { '--state-dir ', 'Directory for broker state and connection files (default: .agentworkforce/relay/)' ) - .option('--broker-name ', 'Override the broker name (defaults to project directory basename)'); + .option('--broker-name ', 'Override the broker name (defaults to project directory basename)') + .option( + '--log-file ', + 'Write structured node logs (capabilities registered, actions invoked/completed) to a file' + ) + .option('--log-level ', 'Node log verbosity: debug | info | warn | error (default: info)') + .option('--log-json', 'Emit node logs as JSON lines instead of text'); } export function registerCoreCommands( diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 2f7d12585..3d9a6eeec 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; import { startServeNode, type FleetNodeDefinition, type RunningNode } from '@agent-relay/fleet'; +import { createLogger } from '@agent-relay/utils'; import type { CoreDependencies, @@ -34,6 +35,12 @@ type UpOptions = { discoverConfig?: boolean; /** Registered node name override (e.g. from a persisted Cloud enrollment). */ nodeName?: string; + /** Write structured node logs (capabilities, action invocations) to this file. */ + logFile?: string; + /** Log verbosity floor: debug | info | warn | error. Defaults to info. */ + logLevel?: string; + /** Emit logs as JSON lines instead of human-readable text. */ + logJson?: boolean; }; type DownOptions = { @@ -138,6 +145,30 @@ function vlog(deps: CoreDependencies, verbose: boolean | undefined, message: str } } +/** True when any log flag (or `--verbose`) opts the node into structured logging. */ +function nodeLoggingEnabled(options: UpOptions): boolean { + return Boolean(options.logFile || options.logLevel || options.logJson || options.verbose); +} + +/** + * Translate the `--log-*` (and `--verbose`) flags into the `AGENT_RELAY_LOG_*` + * environment the shared `createLogger` reads. `--verbose` alone raises the + * floor to DEBUG so per-capability registration lines surface; an explicit + * `--log-level` always wins. Called before the fleet sidecar starts. + */ +function applyNodeLogEnv(options: UpOptions, deps: CoreDependencies): void { + if (options.logFile) { + deps.env.AGENT_RELAY_LOG_FILE = options.logFile; + } + const level = options.logLevel ?? (options.verbose ? 'debug' : undefined); + if (level) { + deps.env.AGENT_RELAY_LOG_LEVEL = level.toUpperCase(); + } + if (options.logJson) { + deps.env.AGENT_RELAY_LOG_JSON = '1'; + } +} + type ErrorWithCode = { code?: unknown }; function isRecord(value: unknown): value is Record { @@ -341,7 +372,13 @@ function startImplicitLocalFleetSidecar( ...(workspaceKey ? { triggers: createTriggerSyncClient({ workspaceKey, baseUrl }) } : {}), statusPath: fleetStatusPath(paths), reconnect: true, - warn: (message) => deps.warn(message), + // With any --log-* flag (or --verbose), surface the node's full lifecycle + // — capabilities registered, every action invoked/completed — through the + // shared logger, which honors AGENT_RELAY_LOG_FILE/_LEVEL/_JSON. Without a + // flag, stay quiet: only warnings reach the console, as before. + ...(nodeLoggingEnabled(options) + ? { logger: createLogger('fleet') } + : { warn: (message) => deps.warn(message) }), }); } catch (err) { deps.warn(`Fleet local node skipped: ${toErrorMessage(err)}`); @@ -986,6 +1023,10 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.env.RELAY_API_KEY = options.workspaceKey; } + // Point the shared logger at a file / level / format before the fleet + // sidecar (which reads this env when it builds its logger) starts. + applyNodeLogEnv(options, deps); + // Resolved BEFORE the broker starts so an explicit bad --config fails // fast instead of tearing down a broker that just came up. const nodeDefinition = await resolveNodeDefinitionForUp(paths, options, deps); diff --git a/packages/fleet/README.md b/packages/fleet/README.md index c6b468619..f7a879ded 100644 --- a/packages/fleet/README.md +++ b/packages/fleet/README.md @@ -73,6 +73,32 @@ await running.stop(); await serveNode({ definition, connection }); ``` +### Logging + +The node runtime emits structured events — each capability it registers and every +action that hits it (invoked / completed / failed, with a duration) — through a +`logger` you inject. The shape matches `@agent-relay/utils`' `createLogger`, and +every event carries a structured `extra` bag (`{ capability, action, invocationId, +ms, … }`) so file and JSON sinks can key on the fields: + +```ts +import { createLogger } from '@agent-relay/utils'; + +await serveNode({ definition, connection, logger: createLogger('fleet') }); +``` + +Via the CLI, `agent-relay node up` surfaces this without code: + +```bash +agent-relay node up --config ./builder.node.ts --log-file ./node.log +agent-relay node up --config ./builder.node.ts --log-level debug # include per-capability lines +agent-relay node up --config ./builder.node.ts --log-json # one JSON object per line +``` + +Capability registration logs at `debug`; action invocations at `info`; failures at +`warn`. When no logger (and no `--log-*` flag) is set, the node stays quiet and only +surfaces warnings. + ## Concepts - **Node** — a named host registered with the workspace. `defineNode` validates the manifest diff --git a/packages/fleet/src/serve-node.test.ts b/packages/fleet/src/serve-node.test.ts index c81c35661..b94db30a9 100644 --- a/packages/fleet/src/serve-node.test.ts +++ b/packages/fleet/src/serve-node.test.ts @@ -13,6 +13,7 @@ import { readFleetSidecarStatus, serveNode, startServeNode, + type FleetLogger, type FleetTriggerSyncClient, type FleetTriggerSyncTrigger, } from './serve-node.js'; @@ -237,6 +238,111 @@ describe('serveNode invoke dispatch', () => { }); }); +describe('serveNode logging', () => { + function recordingLogger() { + const entries: Array<{ level: string; message: string; extra?: Record }> = []; + const record = (level: string) => (message: string, extra?: Record) => { + entries.push({ level, message, ...(extra ? { extra } : {}) }); + }; + const logger: FleetLogger = { + debug: record('debug'), + info: record('info'), + warn: record('warn'), + error: record('error'), + }; + return { logger, entries }; + } + + it('logs each registered capability and a registration summary', async () => { + const { logger, entries } = recordingLogger(); + const controller = new AbortController(); + const registered = new Promise((resolve) => { + void serveNode({ + definition: buildNode(), + connection: { url: broker.url }, + reconnect: false, + signal: controller.signal, + logger, + onRegistered: () => resolve(), + }); + }); + + await registered; + await waitFor(() => entries.some((entry) => entry.message.includes('registered with'))); + + const capabilities = entries.filter((entry) => entry.message.startsWith('Capability')); + expect(capabilities.map((entry) => entry.extra?.capability)).toEqual(['echo', 'explode']); + expect(capabilities.every((entry) => entry.level === 'debug')).toBe(true); + expect(capabilities[0]?.extra).toMatchObject({ capability: 'echo', kind: 'action' }); + + const summary = entries.find((entry) => entry.message.includes('registered with 2 capabilities')); + expect(summary?.level).toBe('info'); + expect(summary?.extra).toMatchObject({ node: 'test-node', capabilities: 2 }); + + controller.abort(); + }); + + it('logs an action invocation and its completion with a duration', async () => { + const { logger, entries } = recordingLogger(); + const controller = new AbortController(); + let socket: WebSocket | undefined; + broker.onConnection((s) => { + socket = s; + }); + + const done = serveNode({ + definition: buildNode(), + connection: { url: broker.url }, + reconnect: false, + signal: controller.signal, + logger, + onRegistered: () => { + broker.sendInvoke(socket!, { invocation_id: 'inv-1', name: 'echo', input: { value: 'hi' } }); + }, + }); + + await waitFor(() => entries.some((entry) => entry.message.includes('completed'))); + const invoked = entries.find((entry) => entry.message === 'Action "echo" invoked'); + expect(invoked?.level).toBe('info'); + expect(invoked?.extra).toMatchObject({ action: 'echo', invocationId: 'inv-1' }); + const completed = entries.find((entry) => entry.message === 'Action "echo" completed'); + expect(completed?.level).toBe('info'); + expect(completed?.extra).toMatchObject({ action: 'echo', invocationId: 'inv-1' }); + expect(typeof completed?.extra?.ms).toBe('number'); + + controller.abort(); + await done; + }); + + it('logs a failed action as a warning carrying the error', async () => { + const { logger, entries } = recordingLogger(); + const controller = new AbortController(); + let socket: WebSocket | undefined; + broker.onConnection((s) => { + socket = s; + }); + + const done = serveNode({ + definition: buildNode(), + connection: { url: broker.url }, + reconnect: false, + signal: controller.signal, + logger, + onRegistered: () => { + broker.sendInvoke(socket!, { invocation_id: 'inv-2', name: 'explode', input: {} }); + }, + }); + + await waitFor(() => entries.some((entry) => entry.message.includes('failed'))); + const failed = entries.find((entry) => entry.message === 'Action "explode" failed'); + expect(failed?.level).toBe('warn'); + expect(failed?.extra).toMatchObject({ action: 'explode', invocationId: 'inv-2', error: 'boom' }); + + controller.abort(); + await done; + }); +}); + describe('serveNode status file', () => { it('writes connected status on registration and disconnected status on stop', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fleet-status-')); diff --git a/packages/fleet/src/serve-node.ts b/packages/fleet/src/serve-node.ts index 6176d6e2a..efa228a31 100644 --- a/packages/fleet/src/serve-node.ts +++ b/packages/fleet/src/serve-node.ts @@ -90,6 +90,23 @@ export interface FleetTriggerSyncClient { delete(id: string): Promise; } +/** + * Structured logger the serve runtime writes to. The shape matches + * `@agent-relay/utils`' `createLogger` so the CLI can inject that logger + * directly, but fleet depends on nothing to keep the seam a plain interface: + * a host may supply any sink (stdout, a file, a JSON collector). + * + * Each method takes a human message plus an optional structured `extra` bag — + * `{ capability, action, invocationId, ms, ... }` — so file/JSON adapters can + * key on the fields rather than parse the message. + */ +export interface FleetLogger { + debug(message: string, extra?: Record): void; + info(message: string, extra?: Record): void; + warn(message: string, extra?: Record): void; + error(message: string, extra?: Record): void; +} + /** * Options for {@link serveNode} / {@link startServeNode}. */ @@ -107,11 +124,38 @@ export interface ServeNodeOptions { statusPath?: string; reconnect?: boolean; signal?: AbortSignal; + /** + * Structured sink for lifecycle and per-invocation events. Preferred over + * `log`/`warn`: capability registration and every action hitting the node are + * emitted here with structured `extra` fields. When omitted, `log`/`warn` + * receive the same events as plain messages. + */ + logger?: FleetLogger; log?: (message: string) => void; warn?: (message: string) => void; onRegistered?: (manifest: ReturnType) => void; } +/** + * Resolve a {@link FleetLogger} from the serve options. An injected `logger` + * wins; otherwise the legacy `log`/`warn` callbacks are adapted (info/debug → + * `log`, warn/error → `warn`), with a no-op fallback so callers can pass + * neither. + */ +function resolveLogger(options: ServeNodeOptions): FleetLogger { + if (options.logger) { + return options.logger; + } + const info = options.log ?? (() => undefined); + const warn = options.warn ?? (() => undefined); + return { + debug: (message) => info(message), + info: (message) => info(message), + warn: (message) => warn(message), + error: (message) => warn(message), + }; +} + /** * Handle returned by {@link startServeNode} for stopping a running node. */ @@ -175,10 +219,11 @@ export function startServeNode(options: ServeNodeOptions): RunningNode { */ export async function serveNode(options: ServeNodeOptions): Promise { const reconnect = options.reconnect ?? true; + const logger = resolveLogger(options); let attempt = 0; while (!options.signal?.aborted) { try { - await runNodeConnection(options); + await runNodeConnection(options, logger); attempt = 0; if (!reconnect) { return; @@ -188,7 +233,10 @@ export async function serveNode(options: ServeNodeOptions): Promise { if (!reconnect || options.signal?.aborted) { throw error; } - options.warn?.(`Fleet node disconnected: ${errorMessage(error)}; reconnecting`); + logger.warn(`Fleet node disconnected: ${errorMessage(error)}; reconnecting`, { + node: options.nameOverride ?? options.definition.name, + error: errorMessage(error), + }); } attempt += 1; @@ -217,7 +265,7 @@ export function buildNodeSupervision(input: { }; } -function runNodeConnection(options: ServeNodeOptions): Promise { +function runNodeConnection(options: ServeNodeOptions, logger: FleetLogger): Promise { return new Promise((resolve, reject) => { const url = fleetWsUrl(options.connection.url); const headers: Record = {}; @@ -281,6 +329,11 @@ function runNodeConnection(options: ServeNodeOptions): Promise { const handleInvoke = async (payload: Extract['payload']) => { const ctx = createActionContext(options, sendRequest, payload.invocation_id); + logger.info(`Action "${payload.name}" invoked`, { + action: payload.name, + invocationId: payload.invocation_id, + }); + const startedAt = Date.now(); // Only a handler failure may be reported as a handler error; a failure to // SEND the result (e.g. socket closed mid-flight) must propagate to the // caller's catch instead of misreporting a successful invocation. @@ -291,6 +344,21 @@ function runNodeConnection(options: ServeNodeOptions): Promise { } catch (error) { invokeError = error; } + const ms = Date.now() - startedAt; + if (invokeError) { + logger.warn(`Action "${payload.name}" failed`, { + action: payload.name, + invocationId: payload.invocation_id, + ms, + error: errorMessage(invokeError), + }); + } else { + logger.info(`Action "${payload.name}" completed`, { + action: payload.name, + invocationId: payload.invocation_id, + ms, + }); + } await sendHandlerResult(payload.invocation_id, output, invokeError); }; @@ -329,9 +397,17 @@ function runNodeConnection(options: ServeNodeOptions): Promise { }); writeStatus(options, true); options.onRegistered?.(manifest); - await syncTriggers(options); - options.log?.( - `Fleet node "${manifest.name}" registered with ${manifest.capabilities.length} capabilities.` + for (const capability of Object.values(options.definition.capabilities)) { + logger.debug(`Capability "${capability.name}" registered`, { + node: manifest.name, + capability: capability.name, + kind: capability.kind, + }); + } + await syncTriggers(options, logger); + logger.info( + `Fleet node "${manifest.name}" registered with ${manifest.capabilities.length} capabilities.`, + { node: manifest.name, capabilities: manifest.capabilities.length } ); })().catch((error) => { close().finally(() => settle(reject, error)); @@ -354,7 +430,7 @@ function runNodeConnection(options: ServeNodeOptions): Promise { } if (frame.type === 'invoke_handler') { void handleInvoke(frame.payload as Extract['payload']).catch( - (error) => options.warn?.(errorMessage(error)) + (error) => logger.warn(errorMessage(error)) ); } }); @@ -411,7 +487,7 @@ function createActionContext( }; } -async function syncTriggers(options: ServeNodeOptions): Promise { +async function syncTriggers(options: ServeNodeOptions, logger: FleetLogger): Promise { const triggers = triggerSyncInputs(options.definition); if (triggers.length === 0 || !options.triggers) { return; @@ -458,7 +534,10 @@ async function syncTriggers(options: ServeNodeOptions): Promise { }) ); } catch (error) { - options.warn?.(`Fleet trigger sync skipped: ${errorMessage(error)}`); + logger.warn(`Fleet trigger sync skipped: ${errorMessage(error)}`, { + node: options.nameOverride ?? options.definition.name, + error: errorMessage(error), + }); } } From ac2343f267bcc431cfb072f3b0a076b605169baf Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 8 Jul 2026 13:15:26 -0400 Subject: [PATCH 2/3] Address review: validate --log-level, forward node/kind, fix docs - Reject an invalid --log-level at parse time (case-insensitive) and fall back to INFO in createLogger for an unrecognized AGENT_RELAY_LOG_LEVEL, instead of silently dropping every log line. - Carry node + capability kind on each action-invocation log line so a file/JSON sink can group by node and kind. - Correct the fleet README: with no logger the node is silent; the CLI wires a warn-only sink when no --log-* flag is given. - Add the CHANGELOG [Unreleased] entry for the new node up log flags. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + packages/cli/src/cli/commands/core.ts | 23 +++++++++++++++++++++-- packages/fleet/README.md | 5 +++-- packages/fleet/src/serve-node.test.ts | 9 +++++++-- packages/fleet/src/serve-node.ts | 24 +++++++++++------------- packages/utils/src/logger.ts | 5 ++++- 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56f18e9f..29a0fba8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay cloud enroll --token ` redeems a one-time Cloud enrollment token and persists node credentials to `~/.agentworkforce/relay/fleet-enrollments.json` (0600); a later plain `agent-relay node up` then runs as the Cloud-managed node. The token is never printed. - `agent-relay node up|down|status|metrics|tail`, `node agent …`, and `node workflow run|logs|sync` unify `local up` and `fleet serve` under one command group. `node up [--config ]` auto-discovers and serves a `defineNode(...)` file (`agent-relay.{ts,tsx,mts,cts,js,mjs,cjs}`) in the project root and picks up persisted Cloud enrollment credentials; with no config it runs the implicit local node from teams.json. - `@agent-relay/fleet` now publishes the node runtime — `serveNode(options)` / `startServeNode(options)` returning `RunningNode { stop(), done }`, plus `FleetTriggerSyncClient`, `buildNodeSupervision`, and `readFleetSidecarStatus` — so one package both authors and serves a node. Fleet no longer depends on `@agent-relay/sdk`. +- `agent-relay node up` gains `--log-file `, `--log-level `, and `--log-json`: a served node logs each capability it registers (`debug`) and every action that hits it (`info`, with a duration and `node`/`kind`/`invocationId` fields; failures at `warn`). Without a flag the node stays quiet apart from warnings; `--verbose` raises the level to `debug`. An invalid `--log-level` is now rejected instead of silently disabling logs. Serving programmatically, inject any sink via `serveNode({ logger })`. - Swift SDK (`AgentRelaySDK`, `packages/sdk-swift`): `AgentClient` gains `invokeAction(_:input:timeout:pollInterval:)` — invoke a relay action and await its output — plus `channelHistory(_:limit:before:)` and `dmHistory(with:limit:before:)` for reading channel and 1:1 DM message history as oldest-first `RelayChannelEvent`s. ### Changed diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 8f3e36bbe..2ad00a10b 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { exec, spawn as spawnProcess } from 'node:child_process'; import { promisify } from 'node:util'; -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; import { getProjectPaths, loadTeamsConfig } from '@agent-relay/config'; import { HarnessDriverClient, type BrokerInitArgs } from '@agent-relay/harness-driver'; @@ -295,10 +295,29 @@ export function addUpCommandOptions(command: Command): Command { '--log-file ', 'Write structured node logs (capabilities registered, actions invoked/completed) to a file' ) - .option('--log-level ', 'Node log verbosity: debug | info | warn | error (default: info)') + .option( + '--log-level ', + 'Node log verbosity: debug | info | warn | error (default: info)', + parseLogLevel + ) .option('--log-json', 'Emit node logs as JSON lines instead of text'); } +const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; + +/** + * Validate `--log-level` at parse time (case-insensitive). Rejecting a typo here + * — rather than passing it through — avoids the silent failure where an + * unrecognized `AGENT_RELAY_LOG_LEVEL` drops every log line. + */ +function parseLogLevel(value: string): string { + const normalized = value.toLowerCase(); + if (!(LOG_LEVELS as readonly string[]).includes(normalized)) { + throw new InvalidArgumentError(`Expected one of: ${LOG_LEVELS.join(', ')}.`); + } + return normalized; +} + export function registerCoreCommands( program: Command, overrides: Partial = {}, diff --git a/packages/fleet/README.md b/packages/fleet/README.md index f7a879ded..e1c62d840 100644 --- a/packages/fleet/README.md +++ b/packages/fleet/README.md @@ -96,8 +96,9 @@ agent-relay node up --config ./builder.node.ts --log-json # one JSON ob ``` Capability registration logs at `debug`; action invocations at `info`; failures at -`warn`. When no logger (and no `--log-*` flag) is set, the node stays quiet and only -surfaces warnings. +`warn`. With no `logger` the node is silent — pass a `logger` (or the older `log`/`warn` +callbacks) to receive events. The CLI wires a `warn`-only sink when no `--log-*` flag is +given, so `agent-relay node up` stays quiet apart from warnings until you opt in. ## Concepts diff --git a/packages/fleet/src/serve-node.test.ts b/packages/fleet/src/serve-node.test.ts index b94db30a9..78516333c 100644 --- a/packages/fleet/src/serve-node.test.ts +++ b/packages/fleet/src/serve-node.test.ts @@ -304,10 +304,15 @@ describe('serveNode logging', () => { await waitFor(() => entries.some((entry) => entry.message.includes('completed'))); const invoked = entries.find((entry) => entry.message === 'Action "echo" invoked'); expect(invoked?.level).toBe('info'); - expect(invoked?.extra).toMatchObject({ action: 'echo', invocationId: 'inv-1' }); + expect(invoked?.extra).toMatchObject({ + node: 'test-node', + action: 'echo', + kind: 'action', + invocationId: 'inv-1', + }); const completed = entries.find((entry) => entry.message === 'Action "echo" completed'); expect(completed?.level).toBe('info'); - expect(completed?.extra).toMatchObject({ action: 'echo', invocationId: 'inv-1' }); + expect(completed?.extra).toMatchObject({ node: 'test-node', action: 'echo', invocationId: 'inv-1' }); expect(typeof completed?.extra?.ms).toBe('number'); controller.abort(); diff --git a/packages/fleet/src/serve-node.ts b/packages/fleet/src/serve-node.ts index efa228a31..e8eb34db4 100644 --- a/packages/fleet/src/serve-node.ts +++ b/packages/fleet/src/serve-node.ts @@ -329,10 +329,17 @@ function runNodeConnection(options: ServeNodeOptions, logger: FleetLogger): Prom const handleInvoke = async (payload: Extract['payload']) => { const ctx = createActionContext(options, sendRequest, payload.invocation_id); - logger.info(`Action "${payload.name}" invoked`, { + // Carry node + kind on every invocation line so a file/JSON sink can group + // a node's activity by node and by capability kind (spawn vs action). + const node = options.nameOverride ?? options.definition.name; + const kind = options.definition.capabilities[payload.name]?.kind; + const base = { + node, action: payload.name, + ...(kind ? { kind } : {}), invocationId: payload.invocation_id, - }); + }; + logger.info(`Action "${payload.name}" invoked`, base); const startedAt = Date.now(); // Only a handler failure may be reported as a handler error; a failure to // SEND the result (e.g. socket closed mid-flight) must propagate to the @@ -346,18 +353,9 @@ function runNodeConnection(options: ServeNodeOptions, logger: FleetLogger): Prom } const ms = Date.now() - startedAt; if (invokeError) { - logger.warn(`Action "${payload.name}" failed`, { - action: payload.name, - invocationId: payload.invocation_id, - ms, - error: errorMessage(invokeError), - }); + logger.warn(`Action "${payload.name}" failed`, { ...base, ms, error: errorMessage(invokeError) }); } else { - logger.info(`Action "${payload.name}" completed`, { - action: payload.name, - invocationId: payload.invocation_id, - ms, - }); + logger.info(`Action "${payload.name}" completed`, { ...base, ms }); } await sendHandlerResult(payload.invocation_id, output, invokeError); }; diff --git a/packages/utils/src/logger.ts b/packages/utils/src/logger.ts index 6fda566a9..6457b71aa 100644 --- a/packages/utils/src/logger.ts +++ b/packages/utils/src/logger.ts @@ -28,7 +28,10 @@ function getLogFile(): string | undefined { } function getLogLevel(): LogLevel { - return (process.env.AGENT_RELAY_LOG_LEVEL ?? 'INFO').toUpperCase() as LogLevel; + const level = (process.env.AGENT_RELAY_LOG_LEVEL ?? 'INFO').toUpperCase(); + // An unrecognized level would make LEVEL_PRIORITY[level] undefined and every + // shouldLog() comparison false — silently dropping all logs. Fall back to INFO. + return (level in LEVEL_PRIORITY ? level : 'INFO') as LogLevel; } function isLogJson(): boolean { From 8c4b99fc8d3976476313805c5e042270cef93ac6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 09:45:38 +0000 Subject: [PATCH 3/3] style: auto-format with Prettier --- packages/fleet/src/serve-node.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/fleet/src/serve-node.ts b/packages/fleet/src/serve-node.ts index 2badccb7f..b7af3a44b 100644 --- a/packages/fleet/src/serve-node.ts +++ b/packages/fleet/src/serve-node.ts @@ -267,11 +267,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { * structured `{ node, action, kind, invocationId }` fields, so a file/JSON sink * can group a node's activity by node and by capability kind (spawn vs action). */ -function adaptHandler( - options: ServeNodeOptions, - name: string, - logger: FleetLogger -): NodeCapabilityHandler { +function adaptHandler(options: ServeNodeOptions, name: string, logger: FleetLogger): NodeCapabilityHandler { const node = options.nameOverride ?? options.definition.name; const kind = options.definition.capabilities[name]?.kind; const base = { node, action: name, ...(kind ? { kind } : {}) };