Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased - Patch]
## [Unreleased - Minor]

### Added

- `agent-relay workspace active --json` now emits `canonical: true` and a per-plane `planes` map so deploys can gate on Relaycast, Relayfile, and RelayAuth resolving one workspace ID (`agent-relay workspace active --json | jq .canonical`). Human output prints a single canonical workspace ID or a clear divergence warning.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Describe canonical as a boolean, not always true.

Divergent planes emit canonical: false; say the command emits a canonical boolean (or canonical: true|false) to avoid misleading deploy-script authors.

Proposed fix
-- `agent-relay workspace active --json` now emits `canonical: true` and a per-plane `planes` map
+- `agent-relay workspace active --json` now emits a `canonical` boolean and a per-plane `planes` map
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `agent-relay workspace active --json` now emits `canonical: true` and a per-plane `planes` map so deploys can gate on Relaycast, Relayfile, and RelayAuth resolving one workspace ID (`agent-relay workspace active --json | jq .canonical`). Human output prints a single canonical workspace ID or a clear divergence warning.
- `agent-relay workspace active --json` now emits a `canonical` boolean and a per-plane `planes` map so deploys can gate on Relaycast, Relayfile, and RelayAuth resolving one workspace ID (`agent-relay workspace active --json | jq .canonical`). Human output prints a single canonical workspace ID or a clear divergence warning.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 12, Update the CHANGELOG entry for `agent-relay
workspace active --json` to describe `canonical` as a boolean that may be true
or false, rather than implying it is always true; retain the existing
explanation of the per-plane `planes` map and divergence warning.


### Fixed

Expand Down
31 changes: 31 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ agent-relay cloud enroll --token ocl_node_enr_...
agent-relay node up
```

### Durable workspace identity

A local Relay node is anchored to one canonical Cloud workspace. That workspace
is the durable identity for the node and every resident agent that runs on it
— across `agent-relay node down` and back up, a reboot, or a crash-restart
supervised by an external watchdog:

- `agent-relay workspace active --json` returns `canonical: true` when the
Relaycast, Relayfile, and RelayAuth workspace IDs all resolve to the same
cloud workspace, plus a `planes` map with the per-plane IDs. Deploys should
gate on `canonical` — a divergent workspace means resident agents may not
keep their delivery addresses across restart.
- `agent-relay node up` reads the pinned workspace from
`.agentworkforce/relay/workspace-key.json` under the current project. When
the file exists the broker resumes it automatically — no `--workspace-key`
copying, and no falling back to the machine-global active workspace.
- Resident agents (auto-spawned from `teams.json`, or spawned from a
`defineNode(...)` config) keep their Relaycast identity across restart. The
broker uses idempotent agent registration in persistent mode and spawned
workers inherit `RELAY_STRICT_AGENT_NAME=1`, so a name like `khaliq-chief`
re-attaches to the same `agent_id` and inbox instead of minting a fresh
process-lifetime identity.
- If a node originally enrolled with `agent-relay cloud enroll`, the enrolled
Fleet node id is stored alongside the workspace key. Subsequent
`agent-relay node up` invocations look up that specific enrollment rather
than picking up whichever enrollment happens to be active machine-wide.

The full invariant, including the on-disk sources of truth and the migration
behavior for existing local nodes, is documented in
[`specs/durable-workspace-identity.md`](../../specs/durable-workspace-identity.md).

## Cloud multiplayer rooms

Cloud room membership is scoped to one Relay workspace. Every v1 invite creates
Expand Down
95 changes: 92 additions & 3 deletions packages/cli/src/cli/commands/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ function createHarness() {
}

describe('registerWorkspaceCommands', () => {
it('prints the active canonical workspace as JSON', async () => {
it('prints the active canonical workspace as JSON with proof the planes align', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
name: 'Ops',
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rc_ops',
// A canonical node has one workspace id shared by every plane — that
// shape lets a resident agent's inbox survive a restart.
relaycastWorkspaceId: 'rw_ops',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
organizationId: 'org_1',
Expand Down Expand Up @@ -89,14 +91,96 @@ describe('registerWorkspaceCommands', () => {
name: 'Ops',
key: 'rk_live_…',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rc_ops',
relaycastWorkspaceId: 'rw_ops',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
organizationId: 'org_1',
slug: 'ops',
urls: {},
apiUrl: 'https://cloud.test',
// The JSON output carries a machine-checkable proof of the durable
// identity invariant so a downstream deploy gate can `jq .canonical`
// rather than re-comparing per-plane ids itself.
canonical: true,
planes: {
cloud: 'rw_ops',
relaycast: 'rw_ops',
relayfile: 'rw_ops',
relayauth: 'rw_ops',
},
});
});

it('workspace active --json flags non-canonical divergence in the JSON payload', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
name: 'Ops',
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rw_ops',
// A Relayfile / RelayAuth id that doesn't match the cloud id would
// break the durable-identity guarantee — assert we surface the split.
relayfileWorkspaceId: 'rw_ops_relayfile_split',
relayauthWorkspaceId: 'rw_ops',
urls: {},
apiUrl: 'https://cloud.test',
});

await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json']);

const parsed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]));
expect(parsed.canonical).toBe(false);
expect(parsed.planes).toEqual({
cloud: 'rw_ops',
relaycast: 'rw_ops',
relayfile: 'rw_ops_relayfile_split',
relayauth: 'rw_ops',
});
});

it('workspace active human output proves the single canonical id when planes agree', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
name: 'Ops',
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rw_ops',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
urls: {},
apiUrl: 'https://cloud.test',
});

await program.parseAsync(['node', 'agent-relay', 'workspace', 'active']);

const logged = vi.mocked(deps.log).mock.calls.map((call) => String(call[0]));
expect(logged).toContain('Workspace: Ops');
expect(logged).toContain('Canonical workspace ID: rw_ops');
expect(logged).toContain(' ✓ Relaycast, Relayfile, and RelayAuth all resolve this workspace');
});

it('workspace active human output warns when the workspace planes diverge', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rw_ops',
relayfileWorkspaceId: 'rw_ops_relayfile_split',
relayauthWorkspaceId: 'rw_ops',
urls: {},
apiUrl: 'https://cloud.test',
});

await program.parseAsync(['node', 'agent-relay', 'workspace', 'active']);

const logged = vi
.mocked(deps.log)
.mock.calls.map((call) => String(call[0]))
.join('\n');
expect(logged).toContain('DIVERGE');
expect(logged).toContain('Relayfile : rw_ops_relayfile_split');
// The divergence branch must NOT print the canonical-id success banner.
expect(logged).not.toContain('Canonical workspace ID:');
});

it('workspace active --json includes raw keys only with --reveal-secrets', async () => {
Expand All @@ -118,6 +202,9 @@ describe('registerWorkspaceCommands', () => {
const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]));
expect(printed.key).toBe('rk_live_ops');
expect(printed.relaycastApiKey).toBe('rk_live_castkey01');
// `--reveal-secrets` still emits the canonical proof; only the raw keys change.
expect(printed.canonical).toBe(false);
expect(printed.planes.relaycast).toBe('rc_ops');
});

it('workspace active --json masks relaycastApiKey by default', async () => {
Expand All @@ -139,6 +226,8 @@ describe('registerWorkspaceCommands', () => {
const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]));
expect(printed.key).toBe('rk_live_…');
expect(printed.relaycastApiKey).toBe('rk_live_…ey01');
// The canonical proof is emitted even when secrets are masked.
expect(printed.canonical).toBe(false);
});

it('workspace create starts and persists a new workspace session', async () => {
Expand Down
85 changes: 70 additions & 15 deletions packages/cli/src/cli/commands/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
import type { Command } from 'commander';
import { InvalidArgumentError } from 'commander';
import { resolveActiveWorkspace } from '@agent-relay/cloud';
import type { ActiveWorkspaceDescriptor } from '@agent-relay/cloud';

import { maskSecret } from '../lib/redact.js';
import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js';
import { readWorkspaceStore, setWorkspaceKey } from '../lib/workspace-store.js';
import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js';

/**
* Proof that Relaycast, Relayfile, and RelayAuth resolve to the same cloud
* workspace — the durable-identity invariant that AR-448 pins in place.
*
* A local Relay node is anchored to ONE cloud workspace id; if the plane-level
* ids diverge, the node cannot deliver messages to a resident agent's inbox on
* Relaycast, mount the same Relayfile paths, or issue tokens through RelayAuth
* against the same identity across a restart. Surfacing this as a
* `canonical: boolean` field lets deploys gate on `jq .canonical` without
* having to re-compare the four service ids themselves.
*/
export interface CanonicalWorkspaceView {
canonical: boolean;
planes: {
cloud: string;
relaycast: string;
relayfile: string;
relayauth: string;
};
}

export function buildCanonicalWorkspaceView(descriptor: ActiveWorkspaceDescriptor): CanonicalWorkspaceView {
const planes = {
cloud: descriptor.cloudWorkspaceId,
relaycast: descriptor.relaycastWorkspaceId,
relayfile: descriptor.relayfileWorkspaceId,
relayauth: descriptor.relayauthWorkspaceId,
} as const;
const canonical =
planes.cloud === planes.relaycast &&
planes.cloud === planes.relayfile &&
planes.cloud === planes.relayauth;
return { canonical, planes };
}

export type WorkspaceCommandDependencies = SdkCommandDeps;

function parsePositiveInteger(value: string): number {
Expand Down Expand Up @@ -49,26 +85,45 @@ export function registerWorkspaceCommands(
refreshTimeoutMs: options.refreshTimeout,
});

const view = buildCanonicalWorkspaceView(workspace);

if (options.json) {
printJson(
deps,
options.revealSecrets
? workspace
: {
...workspace,
key: maskSecret(workspace.key),
...(workspace.relaycastApiKey
? { relaycastApiKey: maskSecret(workspace.relaycastApiKey) }
: {}),
}
);
const body = options.revealSecrets
? workspace
: {
...workspace,
key: maskSecret(workspace.key),
...(workspace.relaycastApiKey
? { relaycastApiKey: maskSecret(workspace.relaycastApiKey) }
: {}),
};
// Include the canonical-invariant proof so a downstream check like
// `agent-relay workspace active --json | jq .canonical` can gate
// deploys on the four service ids matching — without callers having
// to re-compare them or know which fields to look at.
printJson(deps, { ...body, canonical: view.canonical, planes: view.planes });
return;
}

deps.log(`Workspace: ${workspace.name ?? workspace.cloudWorkspaceId}`);
deps.log(`Cloud workspace ID: ${workspace.cloudWorkspaceId}`);
deps.log(`Relayfile workspace ID: ${workspace.relayfileWorkspaceId}`);
deps.log(`Relayauth workspace ID: ${workspace.relayauthWorkspaceId}`);
if (view.canonical) {
// The single-id branch is the healthy shape: Relaycast, Relayfile,
// and RelayAuth are three views of one canonical workspace — a
// restart resolves the same id, so a resident agent's delivery
// address survives.
deps.log(`Canonical workspace ID: ${workspace.cloudWorkspaceId}`);
deps.log(' ✓ Relaycast, Relayfile, and RelayAuth all resolve this workspace');
} else {
// A divergent set means the durable-identity contract is broken
// for this node — surface each plane so an operator can tell which
// service is out of sync before it costs them a resident agent's
// inbox on restart.
deps.log('Workspace planes DIVERGE — durable identity is NOT guaranteed:');
deps.log(` Cloud : ${view.planes.cloud}`);
deps.log(` Relaycast : ${view.planes.relaycast}`);
deps.log(` Relayfile : ${view.planes.relayfile}`);
deps.log(` Relayauth : ${view.planes.relayauth}`);
}
});
}
);
Expand Down
Loading