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
45 changes: 44 additions & 1 deletion packages/cli/src/cli/commands/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ vi.mock('@agent-relay/cloud', () => ({
switchWorkspace: vi.fn(),
}));

import { resolveActiveWorkspace } from '@agent-relay/cloud';
import { resolveActiveWorkspace, setWorkspaceKey } from '@agent-relay/cloud';

import { registerWorkspaceCommands, type WorkspaceCommandDependencies } from './workspace.js';

Expand Down Expand Up @@ -83,4 +83,47 @@ describe('registerWorkspaceCommands', () => {
apiUrl: 'https://cloud.test',
});
});

// Regression coverage for AgentWorkforce/relay#1260: `workspace create` must
// store the Cloud-issued `relaycastApiKey` (which has the Postgres row
// `resolveActiveWorkspace` looks up), not silently store nothing / a key
// from a direct-Relaycast create that would later 404 on `workspace active`.
it('stores the Cloud-issued relaycastApiKey after create and prints it', async () => {
const { program, deps } = createHarness();
vi.mocked(deps.createWorkspace).mockResolvedValueOnce({
workspaceId: 'rw_new',
name: 'new-ws',
relaycastApiKey: 'rk_live_new',
});

await program.parseAsync([
'node',
'agent-relay',
'workspace',
'create',
'new-ws',
'--api-url',
'https://cloud.test',
]);

expect(deps.createWorkspace).toHaveBeenCalledWith('new-ws', 'https://cloud.test');
expect(vi.mocked(setWorkspaceKey)).toHaveBeenCalledWith('new-ws', 'rk_live_new', undefined, 'rw_new');
expect(JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).toEqual({
name: 'new-ws',
workspaceId: 'rw_new',
workspaceKey: 'rk_live_new',
});
});

it('fails loudly instead of storing an unusable workspace when relaycastApiKey is missing', async () => {
const { program, deps } = createHarness();
vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ workspaceId: 'rw_new' });

await expect(
program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'new-ws'])
).rejects.toThrow('exit:1');

expect(vi.mocked(setWorkspaceKey)).not.toHaveBeenCalled();
expect(vi.mocked(deps.error)).toHaveBeenCalledWith(expect.stringContaining('missing relaycastApiKey'));
});
});
23 changes: 17 additions & 6 deletions packages/cli/src/cli/commands/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,27 @@ export function registerWorkspaceCommands(

group
.command('create')
.description('Create a new workspace and store its key')
.description('Create a new workspace (via Cloud) and store its key')
.argument('<name>', 'Workspace name')
.option('--base-url <url>', 'Override the API base URL')
.option('--api-url <url>', 'Cloud API base URL')
.action(async (name: string, o: Record<string, unknown>) => {
await runSdk(deps, async () => {
const relay = await deps.createWorkspace(name, o.baseUrl as string | undefined);
if (relay.workspaceKey) {
setWorkspaceKey(name, relay.workspaceKey);
const workspace = await deps.createWorkspace(name, o.apiUrl as string | undefined);
if (!workspace.relaycastApiKey) {
throw new Error(
'Workspace create response is missing relaycastApiKey — cannot store a usable workspace key. ' +
'Make sure you are logged in to Cloud (`agent-relay login`).'
);
}
printJson(deps, { name, workspaceKey: relay.workspaceKey });
// Cache cloudWorkspaceId immediately — don't wait for the first
// resolve — so self-heal is bound to the workspace just created from
// the start, not left unset until a later successful resolve caches it.
setWorkspaceKey(name, workspace.relaycastApiKey, undefined, workspace.workspaceId);
printJson(deps, {
name,
workspaceId: workspace.workspaceId,
workspaceKey: workspace.relaycastApiKey,
});
});
});

Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/cli/lib/sdk-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';

const { createCloudWorkspaceMock, relaySdkCreateWorkspaceMock } = vi.hoisted(() => ({
createCloudWorkspaceMock: vi.fn(),
relaySdkCreateWorkspaceMock: vi.fn(),
}));

vi.mock('@agent-relay/cloud', () => ({
createWorkspace: createCloudWorkspaceMock,
}));

vi.mock('@agent-relay/sdk', () => ({
AgentRelay: { createWorkspace: relaySdkCreateWorkspaceMock },
}));

vi.mock('./sdk-client.js', () => ({
createAgentRelay: vi.fn(),
createWorkspaceRelay: vi.fn(),
}));

import { withSdkDefaults } from './sdk-command.js';

// Regression coverage for AgentWorkforce/relay#1260: `workspace create` must go
// through Cloud's unified `/api/v1/workspaces` endpoint (which persists the
// Postgres row `workspace active` looks up), never the direct-Relaycast
// `AgentRelay.createWorkspace` path — a key minted that way is a permanent
// orphan that 404s on every future `workspace active` / `agentworkforce login`.
describe('withSdkDefaults createWorkspace', () => {
it('creates a workspace through @agent-relay/cloud, not the direct-Relaycast SDK', async () => {
createCloudWorkspaceMock.mockResolvedValueOnce({
workspaceId: 'rw_new',
relaycastApiKey: 'rk_live_new',
});

const deps = withSdkDefaults();
const result = await deps.createWorkspace('new-ws', 'https://cloud.test');

expect(createCloudWorkspaceMock).toHaveBeenCalledWith('new-ws', { apiUrl: 'https://cloud.test' });
expect(relaySdkCreateWorkspaceMock).not.toHaveBeenCalled();
expect(result).toEqual({ workspaceId: 'rw_new', relaycastApiKey: 'rk_live_new' });
});
});
16 changes: 13 additions & 3 deletions packages/cli/src/cli/lib/sdk-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Command } from 'commander';

import { AgentRelay, type AgentRelayAgent } from '@agent-relay/sdk';
import type { AgentRelayAgent } from '@agent-relay/sdk';
import { createWorkspace as createCloudWorkspace, type WorkspaceCreateResponse } from '@agent-relay/cloud';

import { defaultExit } from './exit.js';
import { createAgentRelay, createWorkspaceRelay, type SdkClientOptions } from './sdk-client.js';
Expand All @@ -11,7 +12,16 @@ type ExitFn = (code: number) => never;
export interface SdkCommandDeps {
createAgentRelay: (options?: SdkClientOptions) => AgentRelayAgent;
createWorkspaceRelay: (options?: SdkClientOptions) => AgentRelayAgent;
createWorkspace: (name: string, baseUrl?: string) => Promise<AgentRelay>;
/**
* Creates a workspace through Cloud's unified `/api/v1/workspaces` endpoint
* (NOT the direct-Relaycast `AgentRelay.createWorkspace`/`RelayCast.createWorkspace`
* path) so the returned `relaycastApiKey` has the Cloud Postgres row that
* `workspace active` / `resolveActiveWorkspace` requires. A key minted
* directly against Relaycast is valid there but is an orphan to Cloud —
* `workspace active` on it 404s with "Workspace not found" even though the
* key itself works. See AgentWorkforce/relay#1260.
*/
createWorkspace: (name: string, apiUrl?: string) => Promise<WorkspaceCreateResponse>;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
exit: ExitFn;
Expand All @@ -21,7 +31,7 @@ export function withSdkDefaults(overrides: Partial<SdkCommandDeps> = {}): SdkCom
return {
createAgentRelay,
createWorkspaceRelay,
createWorkspace: (name, baseUrl) => AgentRelay.createWorkspace({ name, baseUrl }),
createWorkspace: (name, apiUrl) => createCloudWorkspace(name, { apiUrl }),
log: (...args: unknown[]) => console.log(...args),
error: (...args: unknown[]) => console.error(...args),
exit: defaultExit,
Expand Down
18 changes: 18 additions & 0 deletions packages/cloud/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export type AuthSessionResponse = {
export type WorkspaceCreateResponse = {
workspaceId: string;
name?: string;
/**
* The Relaycast `rk_live_` key for this workspace, minted as part of the
* unified Cloud create so it resolves via `resolveActiveWorkspace` (unlike
* a key from a direct Relaycast-only create, which has no Cloud Postgres
* row and 404s on resolve).
*/
relaycastApiKey?: string;
relayfileUrl?: string;
relaycronUrl?: string;
relaycastUrl?: string;
Expand All @@ -89,6 +96,17 @@ export type WorkspaceCreateResponse = {
createdAt?: string;
};

/**
* Response from `POST /api/v1/workspaces/{workspaceId}/join`: session-authenticated
* access to a workspace the caller already owns/belongs to, keyed by its canonical
* Cloud workspace id (NOT a Relaycast-only `workspace create`). Used by
* `joinWorkspace` to self-heal a `relaycastApiKey` that stops resolving.
*/
export type WorkspaceJoinResponse = {
workspaceId: string;
relaycastApiKey: string;
};

export type WorkspaceTokenRecord = {
workspaceId: string;
kind: string;
Expand Down
28 changes: 28 additions & 0 deletions packages/cloud/src/workspace-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
readWorkspaceStore,
resolveActiveWorkspaceEntry,
resolveActiveWorkspaceKey,
setActiveWorkspace,
setWorkspaceKey,
Expand Down Expand Up @@ -52,4 +53,31 @@ describe('workspace store', () => {
expect(() => setWorkspaceKey('__proto__', 'rk_bad')).toThrow(/Invalid workspace name/);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});

it('caches cloudWorkspaceId only when explicitly supplied, and clears it on a plain key update', () => {
setWorkspaceKey('ops', 'rk_ops', undefined, 'rw_ops');
expect(resolveActiveWorkspaceEntry()).toEqual({
name: 'ops',
entry: { key: 'rk_ops', cloudWorkspaceId: 'rw_ops' },
});

// A plain key update with no explicit cloudWorkspaceId must NOT carry the
// old id forward. If it did, a user repointing this alias at a different
// workspace via `workspace set_key`/`workspace join` could have self-heal
// silently rejoin the OLD workspace if the new key transiently fails to
// resolve, instead of surfacing the real failure.
setWorkspaceKey('ops', 'rk_ops_rotated');
expect(resolveActiveWorkspaceEntry()).toEqual({
name: 'ops',
entry: { key: 'rk_ops_rotated' },
});

// An explicit cloudWorkspaceId is stored as given.
setWorkspaceKey('ops', 'rk_ops_rotated', undefined, 'rw_ops_new');
expect(resolveActiveWorkspaceEntry()?.entry.cloudWorkspaceId).toBe('rw_ops_new');
});

it('resolveActiveWorkspaceEntry returns undefined when there is no active workspace', () => {
expect(resolveActiveWorkspaceEntry()).toBeUndefined();
});
});
40 changes: 37 additions & 3 deletions packages/cloud/src/workspace-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,22 @@ import path from 'node:path';
* canonical Agent Relay workspace pin consumed by cloud, workforce, and
* relayfile integrations.
*/
export interface WorkspaceStoreEntry {
key: string;
/**
* The canonical Cloud workspace id this key was last resolved against
* (e.g. `rw_7ccfea89`), cached opportunistically on a successful resolve.
* Lets `resolveActiveWorkspace` self-heal via `joinWorkspace` if this key
* later stops resolving (rotated/orphaned) without the caller needing to
* remember or re-supply the workspace id. Absent for a key that has never
* successfully resolved.
*/
cloudWorkspaceId?: string;
}

export interface WorkspaceStore {
active?: string;
workspaces: Record<string, { key: string }>;
workspaces: Record<string, WorkspaceStoreEntry>;
}

const RESERVED_WORKSPACE_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
Expand Down Expand Up @@ -57,11 +70,22 @@ export function writeWorkspaceStore(store: WorkspaceStore, env: NodeJS.ProcessEn
export function setWorkspaceKey(
name: string,
key: string,
env: NodeJS.ProcessEnv = process.env
env: NodeJS.ProcessEnv = process.env,
cloudWorkspaceId?: string
): WorkspaceStore {
const workspaceName = validateWorkspaceName(name);
const store = readWorkspaceStore(env);
store.workspaces[workspaceName] = { key };
// Only cache cloudWorkspaceId when the caller explicitly supplies it — NOT
// carried forward from a prior entry. A plain key update (e.g. `workspace
// set_key <name> <key>` repointing an alias at a different workspace) must
// not silently retain the OLD cached id: if the new key transiently fails
// to resolve, self-heal would otherwise rejoin the old workspace instead of
// surfacing the failure. Callers that know the id (a fresh resolve, a
// fresh create, a successful join) always pass it explicitly.
store.workspaces[workspaceName] = {
key,
...(cloudWorkspaceId ? { cloudWorkspaceId } : {}),
};
store.active ??= workspaceName;
writeWorkspaceStore(store, env);
return store;
Expand All @@ -88,3 +112,13 @@ export function resolveActiveWorkspaceKey(env: NodeJS.ProcessEnv = process.env):
}

export const activeWorkspaceKey = resolveActiveWorkspaceKey;

/** The active workspace's name plus its full stored entry (key + cached cloudWorkspaceId hint). */
export function resolveActiveWorkspaceEntry(
env: NodeJS.ProcessEnv = process.env
): { name: string; entry: WorkspaceStoreEntry } | undefined {
const store = readWorkspaceStore(env);
if (!store.active) return undefined;
const entry = store.workspaces[store.active];
return entry ? { name: store.active, entry } : undefined;
}
Loading