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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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 <file>]` brings the current context's node online: the broker runs agents, and a project `agent-relay.{ts,…}` or `agent-relay.py` is served as a capability provider. A plain agent host needs no definition file.
- **Node providers** — a node's actions are hosted by providers that connect directly to the engine and are invoked node-addressed (`POST /v1/nodes/:node/actions/:name/invoke`). Author TypeScript actions with `@agent-relay/fleet` (`defineNode`/`serveNode`); an action named `spawn:<harness>` wraps the broker's spawn so any language can adjust the command, env, or cwd before it runs.
- Python `agent_relay.node.NodeProvider.from_enrollment()` serves node actions from Python, reading the node credentials from the enrollment. Install with the `node` extra.
- `agent-relay node up` gains `--log-file <path>`, `--log-level <debug|info|warn|error>`, 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
Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/cli/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -270,6 +270,9 @@ export interface UpCommandOptions {
stateDir?: string;
brokerName?: string;
config?: string;
logFile?: string;
logLevel?: string;
logJson?: boolean;
}

/**
Expand All @@ -287,7 +290,32 @@ export function addUpCommandOptions(command: Command): Command {
'--state-dir <path>',
'Directory for broker state and connection files (default: .agentworkforce/relay/)'
)
.option('--broker-name <name>', 'Override the broker name (defaults to project directory basename)');
.option('--broker-name <name>', 'Override the broker name (defaults to project directory basename)')
.option(
'--log-file <path>',
'Write structured node logs (capabilities registered, actions invoked/completed) to a file'
)
.option(
'--log-level <level>',
'Node log verbosity: debug | info | warn | error (default: info)',
parseLogLevel
)
.option('--log-json', 'Emit node logs as JSON lines instead of text');
Comment thread
willwashburn marked this conversation as resolved.
}

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(
Expand Down
45 changes: 43 additions & 2 deletions packages/cli/src/cli/lib/broker-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, CoreProjectPaths, CoreRelay, SpawnedProcess } from '../commands/core.js';
import { track } from '../telemetry/index.js';
Expand Down Expand Up @@ -32,6 +33,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 = {
Expand Down Expand Up @@ -136,6 +143,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();
Comment thread
willwashburn marked this conversation as resolved.
}
if (options.logJson) {
deps.env.AGENT_RELAY_LOG_JSON = '1';
}
}
Comment thread
willwashburn marked this conversation as resolved.

type ErrorWithCode = { code?: unknown };

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -390,8 +421,14 @@ async function startNodeCapabilityProviders(
providerName: nodeDefinition.name,
...(workspaceKey ? { triggers: createTriggerSyncClient({ workspaceKey, baseUrl }) } : {}),
reconnect: true,
warn: (message) => deps.warn(message),
log: (message) => deps.log(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, keep the prior behavior: the registration summary via log, warnings
// via warn.
...(nodeLoggingEnabled(options)
? { logger: createLogger('fleet') }
: { warn: (message) => deps.warn(message), log: (message) => deps.log(message) }),
})
);
} catch (err) {
Expand Down Expand Up @@ -1099,6 +1136,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);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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);
Expand Down
27 changes: 27 additions & 0 deletions packages/fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,33 @@ 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`. 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

- **Node** — a named host registered with the workspace. `defineNode` validates the manifest
Expand Down
102 changes: 101 additions & 1 deletion packages/fleet/src/serve-node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';

import { action, defineNode, spawn } from './index.js';
import { startServeNode, type FleetTriggerSyncClient } from './serve-node.js';
import { startServeNode, type FleetLogger, type FleetTriggerSyncClient } from './serve-node.js';

/**
* A fake node-ws server: captures frames the client sends and lets the test
Expand Down Expand Up @@ -307,4 +307,104 @@ describe('serveNode', () => {
]);
await running.stop();
});

describe('structured logging', () => {
function recordingLogger() {
const entries: Array<{ level: string; message: string; extra?: Record<string, unknown> }> = [];
const record = (level: string) => (message: string, extra?: Record<string, unknown>) => {
entries.push({ level, message, ...(extra ? { extra } : {}) });
};
const logger: FleetLogger = {
debug: record('debug'),
info: record('info'),
warn: record('warn'),
error: record('error'),
};
return { logger, entries };
}

function loggingNode() {
return defineNode({
name: 'test-node',
capabilities: {
echo: action({ input: z.object({ value: z.string() }) }, async (input) => input),
explode: action({}, async () => {
throw new Error('boom');
}),
},
});
}

it('logs each registered capability at debug and a registration summary at info', async () => {
const { logger, entries } = recordingLogger();
const running = startServeNode({ definition: loggingNode(), connection, reconnect: false, logger });
const sock = socket();
sock.open();
sock.emit(acceptAll(sock.lastRegister()));
await flush();

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({ node: 'test-node', capability: 'echo', kind: 'action' });

const summary = entries.find((entry) => entry.message.includes('with 2 capabilities'));
expect(summary?.level).toBe('info');
expect(summary?.extra).toMatchObject({ node: 'test-node', capabilities: 2 });

await running.stop();
});

it('logs an action invocation and its completion with a duration', async () => {
const { logger, entries } = recordingLogger();
const running = startServeNode({ definition: loggingNode(), connection, reconnect: false, logger });
const sock = socket();
sock.open();
sock.emit(acceptAll(sock.lastRegister()));
await flush();

sock.emit({
v: 1,
type: 'action.invoke',
invocation_id: 'inv-1',
action: 'echo',
input: { value: 'hi' },
});
await flush();

const invoked = entries.find((entry) => entry.message === 'Action "echo" invoked');
expect(invoked?.level).toBe('info');
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({ node: 'test-node', action: 'echo', invocationId: 'inv-1' });
expect(typeof completed?.extra?.ms).toBe('number');

await running.stop();
});

it('logs a failed action as a warning carrying the error', async () => {
const { logger, entries } = recordingLogger();
const running = startServeNode({ definition: loggingNode(), connection, reconnect: false, logger });
const sock = socket();
sock.open();
sock.emit(acceptAll(sock.lastRegister()));
await flush();

sock.emit({ v: 1, type: 'action.invoke', invocation_id: 'inv-2', action: 'explode', input: {} });
await flush();

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' });

await running.stop();
});
});
});
Loading
Loading