diff --git a/.trajectories/completed/2026-05/traj_dpgn0am1jq1c.json b/.trajectories/completed/2026-05/traj_dpgn0am1jq1c.json new file mode 100644 index 000000000..33e7420b6 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_dpgn0am1jq1c.json @@ -0,0 +1,53 @@ +{ + "id": "traj_dpgn0am1jq1c", + "version": 1, + "task": { + "title": "Implement M1 relay CLI bootstrap commands" + }, + "status": "completed", + "startedAt": "2026-05-11T18:43:20.429Z", + "completedAt": "2026-05-11T18:43:20.733Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-05-11T18:43:20.599Z" + } + ], + "chapters": [ + { + "id": "chap_befc8rnp8qu1", + "title": "Work", + "agentName": "default", + "startedAt": "2026-05-11T18:43:20.599Z", + "endedAt": "2026-05-11T18:43:20.733Z", + "events": [ + { + "ts": 1778525000600, + "type": "decision", + "content": "Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands: Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands", + "raw": { + "question": "Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands", + "chosen": "Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands", + "alternatives": [], + "reasoning": "Matches the proactive runtime golden path while reusing the existing cloud auth flow and a reusable @agent-relay/cloud workspace client" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added top-level login/workspaces/tokens CLI commands, reusable cloud workspace client helpers, and verified TypeScript plus the full test suite.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "agent-workforce/relay", + "tags": [], + "_trace": { + "startRef": "49800f05e523320a1597c951698288834ce53f49", + "endRef": "49800f05e523320a1597c951698288834ce53f49" + } +} diff --git a/.trajectories/completed/2026-05/traj_dpgn0am1jq1c.md b/.trajectories/completed/2026-05/traj_dpgn0am1jq1c.md new file mode 100644 index 000000000..9ece4b1d9 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_dpgn0am1jq1c.md @@ -0,0 +1,33 @@ +# Trajectory: Implement M1 relay CLI bootstrap commands + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** May 11, 2026 at 08:43 PM +> **Completed:** May 11, 2026 at 08:43 PM + +--- + +## Summary + +Added top-level login/workspaces/tokens CLI commands, reusable cloud workspace client helpers, and verified TypeScript plus the full test suite. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands + +- **Chose:** Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands +- **Reasoning:** Matches the proactive runtime golden path while reusing the existing cloud auth flow and a reusable @agent-relay/cloud workspace client + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands: Expose login, workspace creation, and workspace token issuance as top-level CLI bootstrap commands diff --git a/.trajectories/completed/2026-05/traj_mi9eqd4rjfea.json b/.trajectories/completed/2026-05/traj_mi9eqd4rjfea.json new file mode 100644 index 000000000..c4b3ea4a4 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_mi9eqd4rjfea.json @@ -0,0 +1,20 @@ +{ + "id": "traj_mi9eqd4rjfea", + "version": 1, + "task": { + "title": "Address stdio fresh review findings" + }, + "status": "abandoned", + "startedAt": "2026-05-11T18:25:24.626Z", + "completedAt": "2026-05-11T18:37:05.318Z", + "agents": [], + "chapters": [], + "commits": [], + "filesChanged": [], + "projectId": "agent-workforce/relay", + "tags": [], + "_trace": { + "startRef": "49800f05e523320a1597c951698288834ce53f49", + "endRef": "49800f05e523320a1597c951698288834ce53f49" + } +} diff --git a/.trajectories/completed/2026-05/traj_mi9eqd4rjfea.md b/.trajectories/completed/2026-05/traj_mi9eqd4rjfea.md new file mode 100644 index 000000000..ecb1f6cbd --- /dev/null +++ b/.trajectories/completed/2026-05/traj_mi9eqd4rjfea.md @@ -0,0 +1,5 @@ +# Trajectory: Address stdio fresh review findings + +> **Status:** ❌ Abandoned +> **Started:** May 11, 2026 at 08:25 PM +> **Completed:** May 11, 2026 at 08:37 PM diff --git a/CHANGELOG.md b/CHANGELOG.md index 71efc1b9a..b75354546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,11 +41,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.0.17] - 2026-05-12 ### Product Perspective + #### User-Facing Features & Improvements + - **Host @agent-relay/events + @agent-relay/agent in relay (#844)** (#844) ### Technical Perspective + #### Releases + - v6.0.17 --- @@ -53,12 +57,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.0.16] - 2026-05-11 ### Product Perspective + #### User-Impacting Fixes + - Drain broker stderr alongside stdout after startup (#842) (#842) - Replace blocking stdout writer task with tokio::io (#841) (#841) ### Technical Perspective + #### Releases + - v6.0.16 --- @@ -66,7 +74,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.0.15] - 2026-05-11 ### Technical Perspective + #### Releases + - v6.0.15 --- diff --git a/package-lock.json b/package-lock.json index 00e727f4b..ffe1eb4f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-relay", - "version": "6.0.16", + "version": "6.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-relay", - "version": "6.0.16", + "version": "6.0.17", "bundleDependencies": [ "@relaycast/sdk", "@relayfile/local-mount" @@ -18,14 +18,14 @@ "web" ], "dependencies": { - "@agent-relay/cloud": "6.0.16", - "@agent-relay/config": "6.0.16", - "@agent-relay/hooks": "6.0.16", - "@agent-relay/sdk": "6.0.16", - "@agent-relay/telemetry": "6.0.16", - "@agent-relay/trajectory": "6.0.16", - "@agent-relay/user-directory": "6.0.16", - "@agent-relay/utils": "6.0.16", + "@agent-relay/cloud": "6.0.17", + "@agent-relay/config": "6.0.17", + "@agent-relay/hooks": "6.0.17", + "@agent-relay/sdk": "6.0.17", + "@agent-relay/telemetry": "6.0.17", + "@agent-relay/trajectory": "6.0.17", + "@agent-relay/user-directory": "6.0.17", + "@agent-relay/utils": "6.0.17", "@aws-sdk/client-s3": "3.1020.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relayauth/core": "^0.1.2", @@ -59,7 +59,8 @@ "zod-to-json-schema": "^3.23.1" }, "bin": { - "agent-relay": "dist/src/cli/index.js" + "agent-relay": "dist/src/cli/index.js", + "relay": "dist/src/cli/index.js" }, "devDependencies": { "@testing-library/dom": "^10.4.1", @@ -16163,10 +16164,10 @@ }, "packages/acp-bridge": { "name": "@agent-relay/acp-bridge", - "version": "6.0.16", + "version": "6.0.17", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "6.0.16", + "@agent-relay/sdk": "6.0.17", "@agentclientprotocol/sdk": "^0.12.0" }, "bin": { @@ -16182,9 +16183,9 @@ }, "packages/agent": { "name": "@agent-relay/agent", - "version": "0.1.0", + "version": "6.0.17", "dependencies": { - "@agent-relay/events": "^0.1.0", + "@agent-relay/events": "6.0.17", "@relaycast/sdk": "1.1.2", "@relayfile/sdk": "^0.7.2" }, @@ -16321,38 +16322,38 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "6.0.16" + "version": "6.0.17" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "6.0.16", + "version": "6.0.17", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "6.0.16", + "version": "6.0.17", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "6.0.16", + "version": "6.0.17", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "6.0.16", + "version": "6.0.17", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "6.0.16", + "version": "6.0.17", "license": "MIT" }, "packages/browser-primitive": { "name": "@agent-relay/browser-primitive", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/sdk": "6.0.16", + "@agent-relay/sdk": "6.0.17", "playwright": "^1.51.1" }, "bin": { @@ -16366,9 +16367,9 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/config": "6.0.16", + "@agent-relay/config": "6.0.17", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -16384,7 +16385,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -16396,7 +16397,7 @@ }, "packages/credential-proxy": { "name": "@agent-relay/credential-proxy", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { "hono": "^4.11.4", "jose": "^6.1.3" @@ -16407,7 +16408,7 @@ }, "packages/events": { "name": "@agent-relay/events", - "version": "0.1.0", + "version": "6.0.17", "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.2.0", @@ -16471,9 +16472,9 @@ }, "packages/gateway": { "name": "@agent-relay/gateway", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/sdk": "6.0.16" + "@agent-relay/sdk": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16482,9 +16483,9 @@ }, "packages/github-primitive": { "name": "@agent-relay/github-primitive", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/workflow-types": "6.0.16" + "@agent-relay/workflow-types": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16494,11 +16495,11 @@ }, "packages/hooks": { "name": "@agent-relay/hooks", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/config": "6.0.16", - "@agent-relay/sdk": "6.0.16", - "@agent-relay/trajectory": "6.0.16" + "@agent-relay/config": "6.0.17", + "@agent-relay/sdk": "6.0.17", + "@agent-relay/trajectory": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16507,9 +16508,9 @@ }, "packages/memory": { "name": "@agent-relay/memory", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/hooks": "6.0.16" + "@agent-relay/hooks": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16518,11 +16519,11 @@ }, "packages/openclaw": { "name": "@agent-relay/openclaw", - "version": "6.0.16", + "version": "6.0.17", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "6.0.16", + "@agent-relay/sdk": "6.0.17", "@relaycast/sdk": "^1.0.0", "ws": "^8.0.0" }, @@ -17287,14 +17288,14 @@ }, "packages/personas": { "name": "@agent-relay/personas", - "version": "6.0.16", + "version": "6.0.17", "license": "MIT" }, "packages/policy": { "name": "@agent-relay/policy", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/config": "6.0.16" + "@agent-relay/config": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -17303,13 +17304,13 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/cloud": "6.0.16", - "@agent-relay/config": "6.0.16", - "@agent-relay/github-primitive": "6.0.16", - "@agent-relay/slack-primitive": "6.0.16", - "@agent-relay/workflow-types": "6.0.16", + "@agent-relay/cloud": "6.0.17", + "@agent-relay/config": "6.0.17", + "@agent-relay/github-primitive": "6.0.17", + "@agent-relay/slack-primitive": "6.0.17", + "@agent-relay/workflow-types": "6.0.17", "@agentworkforce/harness-kit": "^0.11.0", "@agentworkforce/workload-router": "^0.11.0", "@relaycast/sdk": "^1.1.0", @@ -17329,14 +17330,14 @@ "@types/ws": "^8.5.10" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "6.0.16", - "@agent-relay/broker-darwin-x64": "6.0.16", - "@agent-relay/broker-linux-arm64": "6.0.16", - "@agent-relay/broker-linux-x64": "6.0.16", - "@agent-relay/broker-win32-x64": "6.0.16" + "@agent-relay/broker-darwin-arm64": "6.0.17", + "@agent-relay/broker-darwin-x64": "6.0.17", + "@agent-relay/broker-linux-arm64": "6.0.17", + "@agent-relay/broker-linux-x64": "6.0.17", + "@agent-relay/broker-win32-x64": "6.0.17" }, "peerDependencies": { - "@agent-relay/credential-proxy": "6.0.16", + "@agent-relay/credential-proxy": "6.0.17", "@anthropic-ai/claude-agent-sdk": ">=0.1.0", "@google/adk": ">=0.5.0", "@langchain/langgraph": ">=1.2.0", @@ -17374,13 +17375,13 @@ }, "packages/slack-primitive": { "name": "@agent-relay/slack-primitive", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/workflow-types": "6.0.16", + "@agent-relay/workflow-types": "6.0.17", "@slack/web-api": "^7.15.2" }, "devDependencies": { - "@agent-relay/github-primitive": "6.0.16", + "@agent-relay/github-primitive": "6.0.17", "@types/node": "^22.19.3", "typescript": "^5.9.3", "vitest": "^3.2.4" @@ -17388,7 +17389,7 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { "posthog-node": "^5.29.2" }, @@ -17423,9 +17424,9 @@ }, "packages/trajectory": { "name": "@agent-relay/trajectory", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/config": "6.0.16" + "@agent-relay/config": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -17434,9 +17435,9 @@ }, "packages/user-directory": { "name": "@agent-relay/user-directory", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/utils": "6.0.16" + "@agent-relay/utils": "6.0.17" }, "devDependencies": { "@types/node": "^22.19.3", @@ -17445,9 +17446,9 @@ }, "packages/utils": { "name": "@agent-relay/utils", - "version": "6.0.16", + "version": "6.0.17", "dependencies": { - "@agent-relay/config": "6.0.16", + "@agent-relay/config": "6.0.17", "compare-versions": "^6.1.1" }, "devDependencies": { @@ -17457,7 +17458,7 @@ }, "packages/workflow-types": { "name": "@agent-relay/workflow-types", - "version": "6.0.16" + "version": "6.0.17" }, "web": { "version": "0.0.1", diff --git a/package.json b/package.json index 323e3589e..c60d482cc 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "./package.json": "./package.json" }, "bin": { - "agent-relay": "dist/src/cli/index.js" + "agent-relay": "dist/src/cli/index.js", + "relay": "dist/src/cli/index.js" }, "workspaces": [ "packages/*", diff --git a/packages/cloud/src/api-client.ts b/packages/cloud/src/api-client.ts index d0c38bf21..68cf57893 100644 --- a/packages/cloud/src/api-client.ts +++ b/packages/cloud/src/api-client.ts @@ -1,4 +1,4 @@ -import { REFRESH_WINDOW_MS } from "./types.js"; +import { REFRESH_WINDOW_MS } from './types.js'; export type CloudApiClientOptions = { apiUrl: string; @@ -16,12 +16,14 @@ export type CloudApiClientSnapshot = { refreshTokenExpiresAt?: string; }; +type HeaderInput = ConstructorParameters[0]; + function trimLeadingSlash(p: string): string { - return p.replace(/^\/+/, ""); + return p.replace(/^\/+/, ''); } function withTrailingSlash(p: string): string { - return p.endsWith("/") ? p : `${p}/`; + return p.endsWith('/') ? p : `${p}/`; } export function buildApiUrl(apiUrl: string, p: string): URL { @@ -93,10 +95,10 @@ export class CloudApiClient { } async revoke(): Promise { - const response = await fetch(buildApiUrl(this.options.apiUrl, "/api/v1/auth/token/revoke"), { - method: "POST", + const response = await fetch(buildApiUrl(this.options.apiUrl, '/api/v1/auth/token/revoke'), { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ token: this.refreshToken }), }); @@ -123,10 +125,10 @@ export class CloudApiClient { } private async doRefresh(): Promise { - const response = await fetch(buildApiUrl(this.options.apiUrl, "/api/v1/auth/token/refresh"), { - method: "POST", + const response = await fetch(buildApiUrl(this.options.apiUrl, '/api/v1/auth/token/refresh'), { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ refreshToken: this.refreshToken }), }); @@ -143,7 +145,7 @@ export class CloudApiClient { }; if (!payload.accessToken || !payload.accessTokenExpiresAt || !payload.refreshToken) { - throw new Error("Refresh response missing token fields"); + throw new Error('Refresh response missing token fields'); } this.accessToken = payload.accessToken; @@ -152,9 +154,9 @@ export class CloudApiClient { this.refreshTokenExpiresAt = payload.refreshTokenExpiresAt; } - private buildHeaders(headers: HeadersInit | undefined): Headers { + private buildHeaders(headers: HeaderInput | undefined): Headers { const merged = new Headers(headers); - merged.set("Authorization", `Bearer ${this.accessToken}`); + merged.set('Authorization', `Bearer ${this.accessToken}`); return merged; } diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index ff9b40437..3a6da0b6d 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -36,6 +36,18 @@ export { type ConnectProviderResult, } from './connect.js'; +export { createWorkspace, issueWorkspaceToken } from './workspaces.js'; + +export { + deployProactiveAgent, + listProactiveAgents, + inspectProactiveAgent, + undeployProactiveAgent, + createWorkspaceSecret, + getWorkspaceSecret, + deleteWorkspaceSecret, +} from './proactive-runtime.js'; + export { runInteractiveSession, formatShellInvocation, @@ -57,6 +69,12 @@ export { type StoredAuth, type WhoAmIResponse, type AuthSessionResponse, + type WorkspaceCreateResponse, + type WorkspaceTokenIssueResponse, + type WorkspaceTokenRecord, + type ProactiveDeploymentResponse, + type ProactiveAgentRecord, + type WorkspaceSecretRecord, type WorkflowFileType, type RunWorkflowResponse, type WorkflowSchedule, diff --git a/packages/cloud/src/proactive-runtime.ts b/packages/cloud/src/proactive-runtime.ts new file mode 100644 index 000000000..3e958aaf3 --- /dev/null +++ b/packages/cloud/src/proactive-runtime.ts @@ -0,0 +1,418 @@ +import { authorizedApiFetch, ensureAuthenticated } from './auth.js'; +import { + defaultApiUrl, + type ProactiveAgentRecord, + type ProactiveDeploymentResponse, + type WorkspaceSecretRecord, +} from './types.js'; + +type ClientOptions = { + apiUrl?: string; +}; + +type DeployOptions = ClientOptions & { + name?: string; + watch?: boolean; +}; + +type DeployInput = { + entrypoint: string; + source: string; +}; + +type SecretOptions = ClientOptions & { + workspace: string; +}; + +type JsonRecord = Record; + +function isObject(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readString(payload: JsonRecord, key: string): string | undefined { + const value = payload[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function buildEndpointError(action: string, endpoint: string, response: Response, payload: unknown): Error { + const detail = isObject(payload) + ? (readString(payload, 'error') ?? + readString(payload, 'message') ?? + readString(payload, 'detail') ?? + response.statusText) + : response.statusText; + + return new Error(`${action} failed at ${endpoint}: ${response.status} ${detail}`.trim()); +} + +function isUnsupported(response: Response): boolean { + return response.status === 404 || response.status === 405 || response.status === 501; +} + +async function tryRequest( + method: 'GET' | 'POST' | 'DELETE', + endpoint: string, + options: ClientOptions, + body?: Record +): Promise<{ response: Response; payload: unknown }> { + const apiUrl = options.apiUrl || defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl); + const { response } = await authorizedApiFetch(auth, endpoint, { + method, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + return { + response, + payload: await readJson(response), + }; +} + +function normalizeDeploymentResponse(payload: unknown): ProactiveDeploymentResponse { + if (!isObject(payload)) { + throw new Error('Deployment response was not valid JSON.'); + } + + return { + ...payload, + ...(readString(payload, 'deploymentId') ? { deploymentId: readString(payload, 'deploymentId') } : {}), + ...(readString(payload, 'agentId') ? { agentId: readString(payload, 'agentId') } : {}), + ...(readString(payload, 'workspaceId') ? { workspaceId: readString(payload, 'workspaceId') } : {}), + ...(readString(payload, 'status') ? { status: readString(payload, 'status') } : {}), + ...(readString(payload, 'dashboardUrl') ? { dashboardUrl: readString(payload, 'dashboardUrl') } : {}), + ...(readString(payload, 'logsUrl') ? { logsUrl: readString(payload, 'logsUrl') } : {}), + }; +} + +function normalizeAgentRecord(payload: unknown): ProactiveAgentRecord { + if (!isObject(payload)) { + throw new Error('Agent record was not valid JSON.'); + } + + const id = + readString(payload, 'id') ?? + readString(payload, 'agentId') ?? + readString(payload, 'name') ?? + readString(payload, 'displayName'); + if (!id) { + throw new Error('Agent record is missing id.'); + } + + return { + ...payload, + id, + ...(readString(payload, 'name') ? { name: readString(payload, 'name') } : {}), + ...(readString(payload, 'displayName') ? { displayName: readString(payload, 'displayName') } : {}), + ...(readString(payload, 'harness') ? { harness: readString(payload, 'harness') } : {}), + ...(readString(payload, 'defaultModel') ? { defaultModel: readString(payload, 'defaultModel') } : {}), + ...(readString(payload, 'status') ? { status: readString(payload, 'status') } : {}), + ...(typeof payload.credentialStoredAt === 'string' || payload.credentialStoredAt === null + ? { credentialStoredAt: payload.credentialStoredAt as string | null } + : {}), + ...(typeof payload.lastAuthenticatedAt === 'string' || payload.lastAuthenticatedAt === null + ? { lastAuthenticatedAt: payload.lastAuthenticatedAt as string | null } + : {}), + ...(typeof payload.lastUsedAt === 'string' || payload.lastUsedAt === null + ? { lastUsedAt: payload.lastUsedAt as string | null } + : {}), + ...(typeof payload.lastError === 'string' || payload.lastError === null + ? { lastError: payload.lastError as string | null } + : {}), + ...(readString(payload, 'createdAt') ? { createdAt: readString(payload, 'createdAt') } : {}), + ...(readString(payload, 'updatedAt') ? { updatedAt: readString(payload, 'updatedAt') } : {}), + }; +} + +function normalizeAgentList(payload: unknown): ProactiveAgentRecord[] { + if (Array.isArray(payload)) { + return payload.map((entry) => normalizeAgentRecord(entry)); + } + + if (isObject(payload) && Array.isArray(payload.agents)) { + return payload.agents.map((entry) => normalizeAgentRecord(entry)); + } + + throw new Error('Agent list response did not include an agents array.'); +} + +function normalizeSecretRecord(payload: unknown, fallbackName?: string): WorkspaceSecretRecord { + if (!isObject(payload)) { + throw new Error('Secret response was not valid JSON.'); + } + + const name = + readString(payload, 'name') ?? + readString(payload, 'secretName') ?? + readString(payload, 'key') ?? + fallbackName; + if (!name) { + throw new Error('Secret response is missing name.'); + } + + return { + ...payload, + name, + ...(readString(payload, 'value') ? { value: readString(payload, 'value') } : {}), + ...(readString(payload, 'maskedValue') ? { maskedValue: readString(payload, 'maskedValue') } : {}), + ...(readString(payload, 'createdAt') ? { createdAt: readString(payload, 'createdAt') } : {}), + ...(readString(payload, 'updatedAt') ? { updatedAt: readString(payload, 'updatedAt') } : {}), + }; +} + +export async function deployProactiveAgent( + input: DeployInput, + options: DeployOptions = {} +): Promise { + const entrypoint = input.entrypoint.trim(); + if (!entrypoint) { + throw new Error('Entrypoint is required.'); + } + if (!input.source.trim()) { + throw new Error('Entrypoint source is required.'); + } + + const endpoints = [ + '/api/v1/agents/deploy', + '/api/v1/proactive/agents/deploy', + '/api/v1/proactive-runtime/deploy', + ]; + const body = { + entrypoint, + source: input.source, + ...(options.name?.trim() ? { name: options.name.trim() } : {}), + ...(options.watch ? { watch: true } : {}), + }; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('POST', endpoint, options, body); + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Deploy', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Deploy', endpoint, response, payload); + } + return normalizeDeploymentResponse(payload); + } + + throw lastUnsupported ?? new Error('Deploy is not supported by the configured cloud API.'); +} + +export async function listProactiveAgents(options: ClientOptions = {}): Promise { + const endpoints = ['/api/v1/cloud-agents', '/api/v1/agents']; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('GET', endpoint, options); + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Agent list', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Agent list', endpoint, response, payload); + } + return normalizeAgentList(payload); + } + + throw lastUnsupported ?? new Error('Agent listing is not supported by the configured cloud API.'); +} + +export async function inspectProactiveAgent( + agentId: string, + options: ClientOptions = {} +): Promise { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error('Agent id is required.'); + } + + const encoded = encodeURIComponent(trimmed); + const endpoints = [`/api/v1/cloud-agents/${encoded}`, `/api/v1/agents/${encoded}`]; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('GET', endpoint, options); + if (response.status === 404) { + continue; + } + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Agent inspect', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Agent inspect', endpoint, response, payload); + } + return normalizeAgentRecord(payload); + } + + const agents = await listProactiveAgents(options); + const matched = agents.find( + (agent) => agent.id === trimmed || agent.name === trimmed || agent.displayName === trimmed + ); + if (matched) { + return matched; + } + + if (lastUnsupported) { + throw lastUnsupported; + } + throw new Error(`Agent "${trimmed}" not found.`); +} + +export async function undeployProactiveAgent( + agentId: string, + options: ClientOptions = {} +): Promise { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error('Agent id is required.'); + } + + const encoded = encodeURIComponent(trimmed); + const endpoints = [`/api/v1/cloud-agents/${encoded}`, `/api/v1/agents/${encoded}`]; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('DELETE', endpoint, options); + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Agent undeploy', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Agent undeploy', endpoint, response, payload); + } + + if (payload === null) { + return { id: trimmed, status: 'deleted' }; + } + + return normalizeAgentRecord(isObject(payload) ? payload : { id: trimmed, status: 'deleted' }); + } + + throw lastUnsupported ?? new Error('Agent undeploy is not supported by the configured cloud API.'); +} + +export async function createWorkspaceSecret( + name: string, + value: string, + options: SecretOptions +): Promise { + const secretName = name.trim(); + const workspace = options.workspace.trim(); + if (!workspace) { + throw new Error('Workspace is required.'); + } + if (!secretName) { + throw new Error('Secret name is required.'); + } + + const encodedWorkspace = encodeURIComponent(workspace); + const endpoints = [ + `/api/v1/workspaces/${encodedWorkspace}/secrets`, + `/api/v1/workspaces/${encodedWorkspace}/secret`, + `/api/v1/relayauth/workspaces/${encodedWorkspace}/secrets`, + ]; + const body = { name: secretName, value }; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('POST', endpoint, options, body); + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Secret create', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Secret create', endpoint, response, payload); + } + return normalizeSecretRecord(payload, secretName); + } + + throw lastUnsupported ?? new Error('Secret creation is not supported by the configured cloud API.'); +} + +export async function getWorkspaceSecret( + name: string, + options: SecretOptions +): Promise { + const secretName = name.trim(); + const workspace = options.workspace.trim(); + if (!workspace) { + throw new Error('Workspace is required.'); + } + if (!secretName) { + throw new Error('Secret name is required.'); + } + + const encodedWorkspace = encodeURIComponent(workspace); + const encodedName = encodeURIComponent(secretName); + const endpoints = [ + `/api/v1/workspaces/${encodedWorkspace}/secrets/${encodedName}`, + `/api/v1/workspaces/${encodedWorkspace}/secret/${encodedName}`, + `/api/v1/relayauth/workspaces/${encodedWorkspace}/secrets/${encodedName}`, + ]; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('GET', endpoint, options); + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Secret get', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Secret get', endpoint, response, payload); + } + return normalizeSecretRecord(payload, secretName); + } + + throw lastUnsupported ?? new Error('Secret retrieval is not supported by the configured cloud API.'); +} + +export async function deleteWorkspaceSecret( + name: string, + options: SecretOptions +): Promise { + const secretName = name.trim(); + const workspace = options.workspace.trim(); + if (!workspace) { + throw new Error('Workspace is required.'); + } + if (!secretName) { + throw new Error('Secret name is required.'); + } + + const encodedWorkspace = encodeURIComponent(workspace); + const encodedName = encodeURIComponent(secretName); + const endpoints = [ + `/api/v1/workspaces/${encodedWorkspace}/secrets/${encodedName}`, + `/api/v1/workspaces/${encodedWorkspace}/secret/${encodedName}`, + `/api/v1/relayauth/workspaces/${encodedWorkspace}/secrets/${encodedName}`, + ]; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryRequest('DELETE', endpoint, options); + if (isUnsupported(response)) { + lastUnsupported = buildEndpointError('Secret delete', endpoint, response, payload); + continue; + } + if (!response.ok) { + throw buildEndpointError('Secret delete', endpoint, response, payload); + } + return normalizeSecretRecord( + isObject(payload) ? payload : { name: secretName, status: 'deleted' }, + secretName + ); + } + + throw lastUnsupported ?? new Error('Secret deletion is not supported by the configured cloud API.'); +} diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts index fd550b50e..04f0fbcfe 100644 --- a/packages/cloud/src/types.ts +++ b/packages/cloud/src/types.ts @@ -47,6 +47,67 @@ export type AuthSessionResponse = { expiresAt: string; }; +export type WorkspaceCreateResponse = { + workspaceId: string; + name?: string; + relayfileUrl?: string; + relaycronUrl?: string; + relaycastUrl?: string; + relayauthUrl?: string; + joinCommand?: string; + createdAt?: string; +}; + +export type WorkspaceTokenRecord = { + workspaceId: string; + kind: string; + prefix?: string; + id?: string; + name?: string; + createdAt?: string; + updatedAt?: string; +}; + +export type WorkspaceTokenIssueResponse = { + key: string; + workspaceToken?: WorkspaceTokenRecord; +}; + +export type ProactiveDeploymentResponse = { + deploymentId?: string; + agentId?: string; + workspaceId?: string; + status?: string; + dashboardUrl?: string; + logsUrl?: string; + [key: string]: unknown; +}; + +export type ProactiveAgentRecord = { + id: string; + name?: string; + displayName?: string; + harness?: string; + defaultModel?: string; + status?: string; + credentialStoredAt?: string | null; + lastAuthenticatedAt?: string | null; + lastUsedAt?: string | null; + lastError?: string | null; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +}; + +export type WorkspaceSecretRecord = { + name: string; + value?: string; + maskedValue?: string; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +}; + export type WorkflowFileType = 'yaml' | 'ts' | 'py'; export type PathSubmission = { diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts new file mode 100644 index 000000000..b20dace11 --- /dev/null +++ b/packages/cloud/src/workspaces.ts @@ -0,0 +1,209 @@ +import { authorizedApiFetch, ensureAuthenticated } from './auth.js'; +import { + defaultApiUrl, + type WorkspaceCreateResponse, + type WorkspaceTokenIssueResponse, + type WorkspaceTokenRecord, +} from './types.js'; + +type WorkspaceClientOptions = { + apiUrl?: string; +}; + +type WorkspaceTokenIssueOptions = WorkspaceClientOptions & { + name?: string; +}; + +type JsonRecord = Record; + +function isObject(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function readString(payload: JsonRecord, key: string): string | undefined { + const value = payload[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function buildEndpointError(action: string, endpoint: string, response: Response, payload: unknown): Error { + const detail = isObject(payload) + ? (readString(payload, 'error') ?? + readString(payload, 'message') ?? + readString(payload, 'detail') ?? + response.statusText) + : response.statusText; + + return new Error(`${action} failed at ${endpoint}: ${response.status} ${detail}`.trim()); +} + +function normalizeWorkspaceCreateResponse(payload: unknown): WorkspaceCreateResponse { + if (!isObject(payload)) { + throw new Error('Workspace create response was not valid JSON.'); + } + + const workspaceId = readString(payload, 'workspaceId') ?? readString(payload, 'id'); + if (!workspaceId) { + throw new Error('Workspace create response is missing workspaceId.'); + } + + return { + workspaceId, + ...(readString(payload, 'name') ? { name: readString(payload, 'name') } : {}), + ...(readString(payload, 'relayfileUrl') ? { relayfileUrl: readString(payload, 'relayfileUrl') } : {}), + ...(readString(payload, 'relaycronUrl') ? { relaycronUrl: readString(payload, 'relaycronUrl') } : {}), + ...(readString(payload, 'relaycastUrl') ? { relaycastUrl: readString(payload, 'relaycastUrl') } : {}), + ...(readString(payload, 'relayauthUrl') ? { relayauthUrl: readString(payload, 'relayauthUrl') } : {}), + ...(readString(payload, 'joinCommand') ? { joinCommand: readString(payload, 'joinCommand') } : {}), + ...(readString(payload, 'createdAt') ? { createdAt: readString(payload, 'createdAt') } : {}), + }; +} + +function normalizeWorkspaceTokenRecord( + payload: unknown, + fallbackWorkspaceId: string +): WorkspaceTokenRecord | undefined { + if (!isObject(payload)) { + return undefined; + } + + const workspaceId = readString(payload, 'workspaceId') ?? fallbackWorkspaceId; + const kind = readString(payload, 'kind') ?? 'workspace_token'; + const prefix = readString(payload, 'prefix'); + const id = readString(payload, 'id'); + const name = readString(payload, 'name'); + const createdAt = readString(payload, 'createdAt'); + const updatedAt = readString(payload, 'updatedAt'); + + return { + workspaceId, + kind, + ...(prefix ? { prefix } : {}), + ...(id ? { id } : {}), + ...(name ? { name } : {}), + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; +} + +function normalizeWorkspaceTokenIssueResponse( + payload: unknown, + fallbackWorkspaceId: string +): WorkspaceTokenIssueResponse { + if (!isObject(payload)) { + throw new Error('Workspace token response was not valid JSON.'); + } + + const key = readString(payload, 'key') ?? readString(payload, 'token'); + if (!key) { + throw new Error('Workspace token response is missing key.'); + } + + const workspaceToken = normalizeWorkspaceTokenRecord(payload.workspaceToken, fallbackWorkspaceId); + return { + key, + ...(workspaceToken ? { workspaceToken } : {}), + }; +} + +async function tryPostJson( + endpoint: string, + body: Record, + options: WorkspaceClientOptions +): Promise<{ response: Response; payload: unknown }> { + const apiUrl = options.apiUrl || defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl); + const { response } = await authorizedApiFetch(auth, endpoint, { + method: 'POST', + body: JSON.stringify(body), + }); + + return { + response, + payload: await readJson(response), + }; +} + +export async function createWorkspace( + name: string, + options: WorkspaceClientOptions = {} +): Promise { + const requestedName = name.trim(); + if (!requestedName) { + throw new Error('Workspace name is required.'); + } + + const body = { + name: requestedName, + workspaceId: requestedName, + }; + + const endpoints = ['/api/v1/workspaces/create', '/api/v1/workspaces']; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryPostJson(endpoint, body, options); + + if (response.status === 404 || response.status === 405) { + lastUnsupported = buildEndpointError('Workspace create', endpoint, response, payload); + continue; + } + + if (!response.ok) { + throw buildEndpointError('Workspace create', endpoint, response, payload); + } + + return normalizeWorkspaceCreateResponse(payload); + } + + throw lastUnsupported ?? new Error('Workspace create is not supported by the configured cloud API.'); +} + +export async function issueWorkspaceToken( + workspace: string, + options: WorkspaceTokenIssueOptions = {} +): Promise { + const workspaceId = workspace.trim(); + if (!workspaceId) { + throw new Error('Workspace is required.'); + } + + const body = { + workspaceId, + name: options.name?.trim() || `workspace:${workspaceId}`, + }; + + const encodedWorkspaceId = encodeURIComponent(workspaceId); + const endpoints = [ + `/api/v1/workspaces/${encodedWorkspaceId}/tokens/workspace`, + `/api/v1/workspaces/${encodedWorkspaceId}/workspace-token`, + `/api/v1/workspaces/${encodedWorkspaceId}/token`, + ]; + let lastUnsupported: Error | null = null; + + for (const endpoint of endpoints) { + const { response, payload } = await tryPostJson(endpoint, body, options); + + if (response.status === 404 || response.status === 405) { + lastUnsupported = buildEndpointError('Workspace token issue', endpoint, response, payload); + continue; + } + + if (!response.ok) { + throw buildEndpointError('Workspace token issue', endpoint, response, payload); + } + + return normalizeWorkspaceTokenIssueResponse(payload, workspaceId); + } + + throw ( + lastUnsupported ?? new Error('Workspace token issuance is not supported by the configured cloud API.') + ); +} diff --git a/packages/memory/src/context-compaction.ts b/packages/memory/src/context-compaction.ts index acc76dd42..12ee523fd 100644 --- a/packages/memory/src/context-compaction.ts +++ b/packages/memory/src/context-compaction.ts @@ -57,12 +57,12 @@ export interface CompactionResult { } export type CompactionStrategy = - | 'none' // No compaction needed - | 'trim_old' // Remove oldest messages + | 'none' // No compaction needed + | 'trim_old' // Remove oldest messages | 'trim_low_importance' // Remove low-importance messages - | 'summarize' // Summarize and replace old messages - | 'deduplicate' // Remove semantically similar messages - | 'aggressive'; // Combination of all strategies + | 'summarize' // Summarize and replace old messages + | 'deduplicate' // Remove semantically similar messages + | 'aggressive'; // Combination of all strategies export interface CompactionConfig { /** Maximum tokens for context window */ @@ -88,11 +88,11 @@ export interface CompactionConfig { // ============================================================================= const DEFAULT_CONFIG: CompactionConfig = { - maxTokens: 100000, // 100k tokens (Claude's typical limit) - targetUsage: 0.7, // Target 70% after compaction + maxTokens: 100000, // 100k tokens (Claude's typical limit) + targetUsage: 0.7, // Target 70% after compaction compactionThreshold: 0.85, // Trigger at 85% usage minImportanceRetain: 30, // Keep messages with importance >= 30 - keepRecentCount: 10, // Always keep last 10 messages + keepRecentCount: 10, // Always keep last 10 messages enableSummarization: true, enableDeduplication: true, deduplicationThreshold: 0.85, @@ -122,19 +122,47 @@ export function estimateTokens(text: string): number { // Sample-based estimation for longer texts // Count different character types in sample const sampleSize = Math.min(1000, length); - const sample = text.substring(0, sampleSize); - let codeChars = 0; let whitespaceChars = 0; - let _punctuationChars = 0; - - for (let i = 0; i < sample.length; i++) { - const char = sample[i]; - if (/\s/.test(char)) { + for (let i = 0; i < sampleSize; i++) { + const code = text.charCodeAt(i); + if (code <= 32 || code === 127) { whitespaceChars++; - } else if (/[{}[\]();:,.<>!=+\-*/&|^~`@#$%]/.test(char)) { - _punctuationChars++; - codeChars++; + continue; + } + + switch (code) { + case 33: // ! + case 35: // # + case 36: // $ + case 37: // % + case 38: // & + case 40: // ( + case 41: // ) + case 42: // * + case 43: // + + case 44: // , + case 45: // - + case 46: // . + case 47: // / + case 58: // : + case 59: // ; + case 60: // < + case 61: // = + case 62: // > + case 64: // @ + case 91: // [ + case 93: // ] + case 94: // ^ + case 96: // ` + case 123: // { + case 124: // | + case 125: // } + case 126: // ~ + codeChars++; + break; + default: + break; } } @@ -255,8 +283,18 @@ export function calculateImportance(message: Message, index: number, total: numb * Returns 0-1 where 1 = identical. */ export function calculateSimilarity(a: string, b: string): number { - const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2)); - const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2)); + const wordsA = new Set( + a + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 2) + ); + const wordsB = new Set( + b + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 2) + ); if (wordsA.size === 0 || wordsB.size === 0) { return 0; @@ -276,18 +314,12 @@ export function calculateSimilarity(a: string, b: string): number { /** * Find duplicate/similar messages. */ -export function findDuplicates( - messages: Message[], - threshold: number = 0.85 -): Map { +export function findDuplicates(messages: Message[], threshold: number = 0.85): Map { const duplicates = new Map(); for (let i = 0; i < messages.length; i++) { for (let j = i + 1; j < messages.length; j++) { - const similarity = calculateSimilarity( - messages[i].content, - messages[j].content - ); + const similarity = calculateSimilarity(messages[i].content, messages[j].content); if (similarity >= threshold) { const key = messages[i].id; @@ -311,12 +343,13 @@ export function findDuplicates( */ export function createSummary(messages: Message[]): Message { const messageCount = messages.length; - const roles = new Set(messages.map(m => m.role)); - const threads = new Set(messages.filter(m => m.thread).map(m => m.thread)); + const roles = new Set(messages.map((m) => m.role)); + const threads = new Set(messages.filter((m) => m.thread).map((m) => m.thread)); // Extract key sentences (first sentence of each message, or first 100 chars) const keyPoints: string[] = []; - for (const msg of messages.slice(0, 5)) { // Take up to 5 key points + for (const msg of messages.slice(0, 5)) { + // Take up to 5 key points const firstSentence = msg.content.split(/[.!?]\s/)[0]; if (firstSentence && firstSentence.length < 200) { keyPoints.push(`- ${firstSentence}`); @@ -330,7 +363,9 @@ export function createSummary(messages: Message[]): Message { 'Key points:', ...keyPoints, `[End summary]`, - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); return { id: `summary_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, @@ -339,7 +374,7 @@ export function createSummary(messages: Message[]): Message { timestamp: Date.now(), importance: 70, isSummary: true, - summarizes: messages.map(m => m.id), + summarizes: messages.map((m) => m.id), }; } @@ -407,15 +442,20 @@ export class ContextCompactor { // Strategy 1: Deduplicate similar messages if (this.config.enableDeduplication) { + const protectedRecentIds = new Set( + this.config.keepRecentCount > 0 ? result.slice(-this.config.keepRecentCount).map((m) => m.id) : [] + ); const duplicates = findDuplicates(result, this.config.deduplicationThreshold); if (duplicates.size > 0) { const toRemove = new Set(); for (const [, dups] of duplicates) { for (const id of dups) { - toRemove.add(id); + if (!protectedRecentIds.has(id)) { + toRemove.add(id); + } } } - result = result.filter(m => !toRemove.has(m.id)); + result = result.filter((m) => !toRemove.has(m.id)); if (toRemove.size > 0) { strategy = 'deduplicate'; } @@ -433,11 +473,9 @@ export class ContextCompactor { } // Strategy 2: Remove low-importance messages (keep recent) - const recentIds = new Set( - result.slice(-this.config.keepRecentCount).map(m => m.id) - ); + const recentIds = new Set(result.slice(-this.config.keepRecentCount).map((m) => m.id)); - result = result.filter(m => { + result = result.filter((m) => { if (recentIds.has(m.id)) return true; if (m.isSummary) return true; if (m.role === 'system') return true; @@ -461,17 +499,15 @@ export class ContextCompactor { // Strategy 3: Summarize old messages if (this.config.enableSummarization) { - const messagesToSummarize = result.slice(0, -this.config.keepRecentCount) - .filter(m => !m.isSummary && m.role !== 'system'); + const messagesToSummarize = result + .slice(0, -this.config.keepRecentCount) + .filter((m) => !m.isSummary && m.role !== 'system'); if (messagesToSummarize.length >= 3) { const summary = createSummary(messagesToSummarize); - const summaryIds = new Set(messagesToSummarize.map(m => m.id)); + const summaryIds = new Set(messagesToSummarize.map((m) => m.id)); - result = [ - summary, - ...result.filter(m => !summaryIds.has(m.id)), - ]; + result = [summary, ...result.filter((m) => !summaryIds.has(m.id))]; strategy = 'summarize'; @@ -488,7 +524,7 @@ export class ContextCompactor { // Strategy 4: Aggressive trim (last resort) while (estimateContextTokens(result) > targetTokens && result.length > this.config.keepRecentCount + 1) { // Remove oldest non-system, non-summary message - const removeIndex = result.findIndex(m => !m.isSummary && m.role !== 'system'); + const removeIndex = result.findIndex((m) => !m.isSummary && m.role !== 'system'); if (removeIndex === -1) break; result.splice(removeIndex, 1); } @@ -587,16 +623,18 @@ export function benchmarkTokenEstimation(iterations: number = 10000): { 'A'.repeat(10000), // 10k chars ]; + for (const text of testTexts) { + estimateTokens(text); + } + let maxNs = 0; let totalTokens = 0; const start = process.hrtime.bigint(); - for (let i = 0; i < iterations; i++) { for (const text of testTexts) { - const s = process.hrtime.bigint(); - const tokens = estimateTokens(text); - totalTokens += tokens; - const elapsed = Number(process.hrtime.bigint() - s); + const sampleStart = process.hrtime.bigint(); + totalTokens += estimateTokens(text); + const elapsed = Number(process.hrtime.bigint() - sampleStart); if (elapsed > maxNs) maxNs = elapsed; } } diff --git a/packages/utils/src/precompiled-patterns.ts b/packages/utils/src/precompiled-patterns.ts index ea6310f03..56b2c7b95 100644 --- a/packages/utils/src/precompiled-patterns.ts +++ b/packages/utils/src/precompiled-patterns.ts @@ -99,16 +99,16 @@ export function getCompiledPatterns( */ const INSTRUCTIONAL_COMBINED = new RegExp( [ - String.raw`\bSEND:\s*$`, // "SEND:" at end (instruction prefix) + String.raw`\bSEND:\s*$`, // "SEND:" at end (instruction prefix) String.raw`\bPROTOCOL:\s*\(\d+\)`, // "PROTOCOL: (1)" - numbered instructions - String.raw`\bExample:`, // "Example:" marker - String.raw`\\->relay:`, // Escaped relay prefix (documentation) - String.raw`\\->thinking:`, // Escaped thinking prefix (documentation) - String.raw`^AgentName\s+`, // Body starting with "AgentName" - String.raw`^Target\s+`, // Body starting with "Target" - String.raw`\[Agent Relay\]`, // Injected instruction header - String.raw`MULTI-LINE:`, // Multi-line format instruction - String.raw`RECEIVE:`, // Receive instruction marker + String.raw`\bExample:`, // "Example:" marker + String.raw`\\->relay:`, // Escaped relay prefix (documentation) + String.raw`\\->thinking:`, // Escaped thinking prefix (documentation) + String.raw`^AgentName\s+`, // Body starting with "AgentName" + String.raw`^Target\s+`, // Body starting with "Target" + String.raw`\[Agent Relay\]`, // Injected instruction header + String.raw`MULTI-LINE:`, // Multi-line format instruction + String.raw`RECEIVE:`, // Receive instruction marker ].join('|'), 'i' // Case insensitive ); @@ -197,6 +197,16 @@ function replaceOrphanedCsiPrefix(match: string): string { * Uses precompiled patterns for better performance. */ export function stripAnsiFast(str: string): string { + if (str.length === 0) { + return str; + } + + const hasEscape = str.indexOf('\x1b') !== -1; + const hasCarriageReturn = str.indexOf('\r') !== -1; + if (!hasEscape && !hasCarriageReturn && str.indexOf('[') === -1) { + return str; + } + // Reset lastIndex for global patterns CURSOR_FORWARD_CSI_COMPILED.lastIndex = 0; ORPHANED_CURSOR_FORWARD_CSI_COMPILED.lastIndex = 0; @@ -274,16 +284,16 @@ export const StaticPatterns = { * Check if line is a spawn or release command. */ export function isSpawnOrReleaseCommandFast(line: string): boolean { - return StaticPatterns.SPAWN_COMMAND.test(line) || - StaticPatterns.RELEASE_COMMAND.test(line); + return StaticPatterns.SPAWN_COMMAND.test(line) || StaticPatterns.RELEASE_COMMAND.test(line); } /** * Check if a line contains an escaped fence end. */ export function isEscapedFenceEndFast(line: string): boolean { - return StaticPatterns.ESCAPED_FENCE_END_CHECK.test(line) || - StaticPatterns.ESCAPED_FENCE_START_CHECK.test(line); + return ( + StaticPatterns.ESCAPED_FENCE_END_CHECK.test(line) || StaticPatterns.ESCAPED_FENCE_START_CHECK.test(line) + ); } /** @@ -374,13 +384,16 @@ export function benchmarkPatterns( // Benchmark combined instructional check { let maxNs = 0; + for (const str of testStrings) { + const sampleStart = process.hrtime.bigint(); + isInstructionalTextFast(str); + const elapsed = Number(process.hrtime.bigint() - sampleStart); + if (elapsed > maxNs) maxNs = elapsed; + } const start = process.hrtime.bigint(); for (let i = 0; i < iterations; i++) { for (const str of testStrings) { - const s = process.hrtime.bigint(); isInstructionalTextFast(str); - const elapsed = Number(process.hrtime.bigint() - s); - if (elapsed > maxNs) maxNs = elapsed; } } const totalNs = Number(process.hrtime.bigint() - start); @@ -393,13 +406,16 @@ export function benchmarkPatterns( // Benchmark ANSI stripping { let maxNs = 0; + for (const str of testStrings) { + const sampleStart = process.hrtime.bigint(); + stripAnsiFast(str); + const elapsed = Number(process.hrtime.bigint() - sampleStart); + if (elapsed > maxNs) maxNs = elapsed; + } const start = process.hrtime.bigint(); for (let i = 0; i < iterations; i++) { for (const str of testStrings) { - const s = process.hrtime.bigint(); stripAnsiFast(str); - const elapsed = Number(process.hrtime.bigint() - s); - if (elapsed > maxNs) maxNs = elapsed; } } const totalNs = Number(process.hrtime.bigint() - start); @@ -413,13 +429,16 @@ export function benchmarkPatterns( { const targets = ['AgentName', 'Lead', 'Worker', 'target', 'Developer']; let maxNs = 0; + for (const target of targets) { + const sampleStart = process.hrtime.bigint(); + isPlaceholderTargetFast(target); + const elapsed = Number(process.hrtime.bigint() - sampleStart); + if (elapsed > maxNs) maxNs = elapsed; + } const start = process.hrtime.bigint(); for (let i = 0; i < iterations; i++) { - for (const t of targets) { - const s = process.hrtime.bigint(); - isPlaceholderTargetFast(t); - const elapsed = Number(process.hrtime.bigint() - s); - if (elapsed > maxNs) maxNs = elapsed; + for (const target of targets) { + isPlaceholderTargetFast(target); } } const totalNs = Number(process.hrtime.bigint() - start); diff --git a/src/cli/bootstrap.test.ts b/src/cli/bootstrap.test.ts index 08f9902fd..a8d24055c 100644 --- a/src/cli/bootstrap.test.ts +++ b/src/cli/bootstrap.test.ts @@ -24,6 +24,7 @@ const expectedLeafCommands = [ 'read', 'history', 'inbox', + 'login', 'metrics', 'health', 'profile', @@ -36,7 +37,13 @@ const expectedLeafCommands = [ 'off', 'run', 'connect', + 'dlq list', + 'dlq inspect', + 'dlq replay', + 'dlq purge', 'workflows list', + 'workspaces create', + 'tokens issue', 'cloud login', 'cloud logout', 'cloud whoami', @@ -100,6 +107,7 @@ describe('bootstrap CLI', () => { 'read', 'history', 'inbox', + 'login', 'cloud', 'metrics', 'health', @@ -112,7 +120,10 @@ describe('bootstrap CLI', () => { 'on', 'off', 'run', + 'dlq', + 'workspaces', 'workflows', + 'tokens', ]) ); expect(topLevelCommands).not.toContain('create-agent'); diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index 21f9a2f4f..291a89fd6 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -16,13 +16,16 @@ import { errorClassName } from './lib/telemetry-helpers.js'; import { registerAgentManagementCommands } from './commands/agent-management.js'; import { registerMessagingCommands } from './commands/messaging.js'; import { registerCloudCommands } from './commands/cloud.js'; +import { registerProactiveBootstrapCommands } from './commands/proactive-bootstrap.js'; import { registerMonitoringCommands } from './commands/monitoring.js'; import { registerAuthCommands } from './commands/auth.js'; import { registerSetupCommands } from './commands/setup.js'; import { registerCoreCommands } from './commands/core.js'; +import { registerRelayRuntimeCommands } from './commands/relay-runtime.js'; import { registerSwarmCommands } from './commands/swarm.js'; import { registerConnectCommands } from './commands/connect.js'; import { registerOnCommands } from './commands/on.js'; +import { registerDlqCommands } from './commands/dlq.js'; dotenvConfig({ quiet: true }); @@ -79,6 +82,16 @@ function resolveSdkVersion(): string | undefined { export const SDK_VERSION = resolveSdkVersion(); +function resolveProgramName(argv: string[] = process.argv): string { + const invocationPath = String(argv[1] ?? '').trim(); + if (!invocationPath) { + return 'agent-relay'; + } + + const commandName = path.basename(invocationPath).trim().toLowerCase(); + return commandName === 'relay' ? 'relay' : 'agent-relay'; +} + /** * Export the resolved CLI + SDK versions on the current process env so that * any child process we spawn (the Rust broker, the dashboard server, etc.) @@ -247,11 +260,11 @@ function installExitHooks(): void { }); } -export function createProgram(): Command { +export function createProgram(options: { name?: string } = {}): Command { const program = new Command(); program - .name('agent-relay') + .name(options.name ?? 'agent-relay') .description('Agent-to-agent messaging') .version(VERSION, '-V, --version', 'Output the version number'); @@ -259,12 +272,15 @@ export function createProgram(): Command { registerAgentManagementCommands(program); registerMessagingCommands(program); registerCloudCommands(program); + registerProactiveBootstrapCommands(program); registerMonitoringCommands(program); registerAuthCommands(program); + registerRelayRuntimeCommands(program); registerSetupCommands(program); registerSwarmCommands(program); registerOnCommands(program); registerConnectCommands(program); + registerDlqCommands(program); return program; } @@ -292,7 +308,7 @@ export async function runCli(argv: string[] = process.argv): Promise { }); } - const program = createProgram(); + const program = createProgram({ name: resolveProgramName(argv) }); installTelemetryHooks(program); installExitHooks(); diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index 1bbb7af9a..87482cc8c 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -174,7 +174,7 @@ export function registerCloudCommands(program: Command, overrides: Partial', 'Cloud API base URL') .option('--force', 'Force re-authentication even if already logged in') .action(async (options: { apiUrl?: string; force?: boolean }) => { diff --git a/src/cli/commands/dlq.test.ts b/src/cli/commands/dlq.test.ts new file mode 100644 index 000000000..aa4f735f2 --- /dev/null +++ b/src/cli/commands/dlq.test.ts @@ -0,0 +1,309 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Command } from 'commander'; +import { type StoredAuth } from '@agent-relay/cloud'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { registerDlqCommands, type DlqDependencies } from './dlq.js'; + +class ExitSignal extends Error { + constructor(public readonly code: number) { + super(`exit:${code}`); + } +} + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-dlq-')); + tempRoots.push(root); + return root; +} + +function writeDlqRecord(root: string, workspace: string, fileName: string, record: unknown): void { + const dir = path.join(root, '_dlq', workspace); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, fileName), `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); +} + +function createHarness(options?: { + projectRoot?: string; + fetchImpl?: typeof fetch; + authorizedApiFetchImpl?: DlqDependencies['authorizedApiFetch']; + now?: number; +}) { + const projectRoot = options?.projectRoot ?? makeTempRoot(); + const fetchImpl = + options?.fetchImpl ?? + (vi.fn( + async () => ({ ok: true, status: 202, text: async () => '' }) as unknown as Response + ) as typeof fetch); + const exit = vi.fn((code: number) => { + throw new ExitSignal(code); + }) as unknown as DlqDependencies['exit']; + + const deps: DlqDependencies = { + getProjectRoot: vi.fn(() => projectRoot), + fs, + fetch: fetchImpl, + ensureAuthenticated: vi.fn( + async () => ({ accessToken: 'access', refreshToken: 'refresh' }) as StoredAuth + ), + authorizedApiFetch: + options?.authorizedApiFetchImpl ?? + vi.fn(async () => ({ + auth: { accessToken: 'access', refreshToken: 'refresh' } as StoredAuth, + response: new Response(JSON.stringify({ error: 'unsupported' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }), + })), + env: {}, + defaultCloudUrl: 'https://cloud.test', + log: vi.fn(() => undefined), + error: vi.fn(() => undefined), + exit, + now: vi.fn(() => options?.now ?? Date.parse('2026-05-12T12:00:00.000Z')), + }; + + const program = new Command(); + program.configureOutput({ + writeOut: () => undefined, + writeErr: () => undefined, + }); + program.exitOverride(); + registerDlqCommands(program, deps); + + return { program, deps, projectRoot, fetchImpl }; +} + +async function runCommand(program: Command, args: string[]): Promise { + try { + await program.parseAsync(args, { from: 'user' }); + return undefined; + } catch (err: any) { + if (err instanceof ExitSignal) { + return err.code; + } + if (typeof err?.exitCode === 'number') { + return err.exitCode; + } + throw err; + } +} + +afterEach(() => { + while (tempRoots.length > 0) { + fs.rmSync(tempRoots.pop()!, { recursive: true, force: true }); + } +}); + +describe('registerDlqCommands', () => { + it('registers the dlq command group', () => { + const { program } = createHarness(); + expect(program.commands.map((command) => command.name())).toContain('dlq'); + }); + + it('lists summarized DLQ records for a workspace', async () => { + const { program, deps, projectRoot } = createHarness(); + writeDlqRecord(projectRoot, 'support', 'evt_alpha.json', { + event: { id: 'evt_alpha', type: 'message.created' }, + error: { message: 'timeout' }, + attemptCount: 3, + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-11T11:00:00.000Z', + }); + writeDlqRecord(projectRoot, 'support', 'evt_beta.json', { + event_id: 'evt_beta', + type: 'dm.received', + error: 'unsupported_operation', + attempts: 1, + created_at: '2026-05-09T09:00:00.000Z', + updated_at: '2026-05-09T09:30:00.000Z', + }); + + const exitCode = await runCommand(program, ['dlq', 'list', '--workspace', 'support']); + + expect(exitCode).toBeUndefined(); + expect(deps.log).toHaveBeenNthCalledWith( + 1, + 'evt_alpha | message.created | attempts=3 | first=2026-05-10T10:00:00.000Z | last=2026-05-11T11:00:00.000Z | error=timeout' + ); + expect(deps.log).toHaveBeenNthCalledWith( + 2, + 'evt_beta | dm.received | attempts=1 | first=2026-05-09T09:00:00.000Z | last=2026-05-09T09:30:00.000Z | error=unsupported_operation' + ); + }); + + it('prints the full record for inspect', async () => { + const { program, deps, projectRoot } = createHarness(); + const record = { + event: { id: 'evt_inspect', type: 'thread.reply' }, + error: { message: 'delivery_failed' }, + attemptCount: 2, + }; + writeDlqRecord(projectRoot, 'ops', 'evt_inspect.json', record); + + const exitCode = await runCommand(program, ['dlq', 'inspect', '--workspace', 'ops', 'evt_inspect']); + + expect(exitCode).toBeUndefined(); + expect(deps.log).toHaveBeenCalledWith(JSON.stringify(record, null, 2)); + }); + + it('replays all records through their replay metadata when --all is passed', async () => { + const fetchImpl = vi.fn( + async () => ({ ok: true, status: 202, text: async () => '' }) as unknown as Response + ) as typeof fetch; + const { program, deps, projectRoot } = createHarness({ fetchImpl }); + writeDlqRecord(projectRoot, 'sales', 'evt_one.json', { + event: { id: 'evt_one', type: 'message.created' }, + replay: { + url: 'http://127.0.0.1:18790/replay', + body: { eventId: 'evt_one' }, + }, + }); + writeDlqRecord(projectRoot, 'sales', 'evt_two.json', { + event: { id: 'evt_two', type: 'dm.received' }, + replay: { + url: 'http://127.0.0.1:18790/replay', + body: { eventId: 'evt_two' }, + }, + }); + + const exitCode = await runCommand(program, ['dlq', 'replay', '--workspace', 'sales', '--all']); + + expect(exitCode).toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + 'http://127.0.0.1:18790/replay', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ eventId: 'evt_one' }), + }) + ); + expect(deps.log).toHaveBeenNthCalledWith(1, 'Replayed evt_one -> http://127.0.0.1:18790/replay (202)'); + expect(deps.log).toHaveBeenNthCalledWith(2, 'Replayed evt_two -> http://127.0.0.1:18790/replay (202)'); + }); + + it('purges only records older than the requested duration', async () => { + const { program, deps, projectRoot } = createHarness({ + now: Date.parse('2026-05-12T12:00:00.000Z'), + }); + writeDlqRecord(projectRoot, 'eng', 'evt_old.json', { + event: { id: 'evt_old', type: 'message.created' }, + lastSeenAt: '2026-05-10T09:00:00.000Z', + }); + writeDlqRecord(projectRoot, 'eng', 'evt_new.json', { + event: { id: 'evt_new', type: 'message.created' }, + lastSeenAt: '2026-05-12T11:30:00.000Z', + }); + + const exitCode = await runCommand(program, ['dlq', 'purge', '--workspace', 'eng', '--older-than', '24h']); + + expect(exitCode).toBeUndefined(); + expect(fs.existsSync(path.join(projectRoot, '_dlq', 'eng', 'evt_old.json'))).toBe(false); + expect(fs.existsSync(path.join(projectRoot, '_dlq', 'eng', 'evt_new.json'))).toBe(true); + expect(deps.log).toHaveBeenCalledWith('Purged 1 DLQ record(s) from workspace "eng".'); + }); + + it('prefers the cloud workspace DLQ APIs when they are available', async () => { + const authorizedApiFetchImpl = vi.fn(async (_auth, requestPath, init) => { + if (requestPath === '/api/v1/workspaces/support/dlq' && init?.method === 'DELETE') { + return { + auth: { accessToken: 'access', refreshToken: 'refresh' } as StoredAuth, + response: new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }; + } + if (requestPath === '/api/v1/workspaces/support/dlq/evt_remote/replay') { + return { + auth: { accessToken: 'access', refreshToken: 'refresh' } as StoredAuth, + response: new Response(JSON.stringify({ ok: true }), { + status: 202, + headers: { 'content-type': 'application/json' }, + }), + }; + } + if (requestPath === '/api/v1/workspaces/support/dlq/evt_remote') { + return { + auth: { accessToken: 'access', refreshToken: 'refresh' } as StoredAuth, + response: new Response( + JSON.stringify({ + ok: true, + data: { + event: { id: 'evt_remote', type: 'message.created' }, + error: { message: 'remote_timeout' }, + attemptCount: 2, + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-11T11:00:00.000Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ), + }; + } + return { + auth: { accessToken: 'access', refreshToken: 'refresh' } as StoredAuth, + response: new Response( + JSON.stringify({ + ok: true, + data: { + items: [{ eventId: 'evt_remote' }], + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ), + }; + }) as DlqDependencies['authorizedApiFetch']; + const { program, deps } = createHarness({ authorizedApiFetchImpl }); + + const listExitCode = await runCommand(program, ['dlq', 'list', '--workspace', 'support']); + expect(listExitCode).toBeUndefined(); + expect(deps.log).toHaveBeenCalledWith( + 'evt_remote | message.created | attempts=2 | first=2026-05-10T10:00:00.000Z | last=2026-05-11T11:00:00.000Z | error=remote_timeout' + ); + + const inspectExitCode = await runCommand(program, [ + 'dlq', + 'inspect', + '--workspace', + 'support', + 'evt_remote', + ]); + expect(inspectExitCode).toBeUndefined(); + expect(deps.log).toHaveBeenCalledWith( + JSON.stringify( + { + event: { id: 'evt_remote', type: 'message.created' }, + error: { message: 'remote_timeout' }, + attemptCount: 2, + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-11T11:00:00.000Z', + }, + null, + 2 + ) + ); + + const replayExitCode = await runCommand(program, [ + 'dlq', + 'replay', + '--workspace', + 'support', + 'evt_remote', + ]); + expect(replayExitCode).toBeUndefined(); + expect(deps.log).toHaveBeenCalledWith('Replayed evt_remote via cloud workspace API.'); + + const purgeExitCode = await runCommand(program, ['dlq', 'purge', '--workspace', 'support']); + expect(purgeExitCode).toBeUndefined(); + expect(deps.log).toHaveBeenCalledWith( + 'Purged DLQ records from workspace "support" via cloud workspace API.' + ); + }); +}); diff --git a/src/cli/commands/dlq.ts b/src/cli/commands/dlq.ts new file mode 100644 index 000000000..fb09ec9ec --- /dev/null +++ b/src/cli/commands/dlq.ts @@ -0,0 +1,640 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { Command, InvalidArgumentError } from 'commander'; + +import { authorizedApiFetch, defaultApiUrl, ensureAuthenticated } from '@agent-relay/cloud'; +import { getProjectPaths } from '@agent-relay/config'; + +import { defaultExit } from '../lib/exit.js'; + +type ExitFn = (code: number) => never; + +type JsonObject = Record; + +interface DlqRecordEntry { + fileName: string; + filePath: string; + record: JsonObject; + summary: DlqRecordSummary; +} + +interface DlqRecordSummary { + eventId: string; + type: string; + error: string; + attempts: number; + firstSeen: string; + lastSeen: string; +} + +export interface DlqDependencies { + getProjectRoot: () => string; + fs: Pick; + fetch: typeof fetch; + ensureAuthenticated: typeof ensureAuthenticated; + authorizedApiFetch: typeof authorizedApiFetch; + env: NodeJS.ProcessEnv; + defaultCloudUrl: string; + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: ExitFn; + now: () => number; +} + +function withDefaults(overrides: Partial = {}): DlqDependencies { + return { + getProjectRoot: () => getProjectPaths().projectRoot, + fs, + fetch, + ensureAuthenticated, + authorizedApiFetch, + env: process.env, + defaultCloudUrl: defaultApiUrl(), + log: (...args: unknown[]) => console.log(...args), + error: (...args: unknown[]) => console.error(...args), + exit: defaultExit, + now: () => Date.now(), + ...overrides, + }; +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +function readNumber(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return undefined; +} + +function normalizeTimestamp(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string' || !value.trim()) continue; + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return new Date(parsed).toISOString(); + } + } + return undefined; +} + +function readObject(...values: unknown[]): JsonObject | undefined { + for (const value of values) { + if (isObject(value)) { + return value; + } + } + return undefined; +} + +function parseDurationToMs(raw: string): number { + const value = raw.trim().toLowerCase(); + const match = value.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/); + if (!match) { + throw new InvalidArgumentError('Expected duration like 30m, 12h, 7d, or 500ms.'); + } + + const amount = Number.parseFloat(match[1]); + const unit = match[2]; + const multiplier = + unit === 'ms' + ? 1 + : unit === 's' + ? 1_000 + : unit === 'm' + ? 60_000 + : unit === 'h' + ? 3_600_000 + : unit === 'd' + ? 86_400_000 + : 604_800_000; + + return Math.floor(amount * multiplier); +} + +function assertWorkspaceName(workspace: string): string { + const trimmed = workspace.trim(); + if (!trimmed) { + throw new InvalidArgumentError('Workspace name is required.'); + } + return trimmed; +} + +function resolveWorkspaceDir(projectRoot: string, workspace: string): string { + const baseDir = path.resolve(projectRoot, '_dlq'); + const workspaceDir = path.resolve(baseDir, workspace); + if (workspaceDir !== baseDir && !workspaceDir.startsWith(`${baseDir}${path.sep}`)) { + throw new InvalidArgumentError('Workspace name must not escape the _dlq directory.'); + } + return workspaceDir; +} + +function summarizeDlqRecord(fileName: string, record: JsonObject): DlqRecordSummary { + const event = readObject(record.event); + const fileStem = path.basename(fileName, path.extname(fileName)); + const eventId = + readString(record.eventId, record.event_id, event?.id, event?.eventId, event?.event_id, fileStem) ?? + fileStem; + const type = readString(record.type, event?.type, record.eventType, record.event_type) ?? 'unknown'; + const errorValue = readObject(record.error); + const error = + readString( + record.error, + errorValue?.message, + errorValue?.code, + record.reason, + record.lastError, + record.last_error + ) ?? 'unknown'; + const attempts = Math.max( + 1, + Math.trunc( + readNumber( + record.attempts, + record.attemptCount, + record.attempt_count, + readObject(record.delivery)?.attempts, + readObject(record.delivery)?.attemptCount + ) ?? 1 + ) + ); + const firstSeen = + normalizeTimestamp(record.firstSeenAt, record.first_seen_at, record.createdAt, record.created_at) ?? + 'unknown'; + const lastSeen = + normalizeTimestamp( + record.lastSeenAt, + record.last_seen_at, + record.updatedAt, + record.updated_at, + firstSeen + ) ?? firstSeen; + + return { eventId, type, error, attempts, firstSeen, lastSeen }; +} + +function loadWorkspaceRecords( + deps: DlqDependencies, + workspace: string +): { workspaceDir: string; records: DlqRecordEntry[] } { + const workspaceDir = resolveWorkspaceDir(deps.getProjectRoot(), assertWorkspaceName(workspace)); + if (!deps.fs.existsSync(workspaceDir)) { + return { workspaceDir, records: [] }; + } + + const records = deps.fs + .readdirSync(workspaceDir) + .filter((name) => name.endsWith('.json')) + .sort() + .map((fileName) => { + const filePath = path.join(workspaceDir, fileName); + const raw = deps.fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (!isObject(parsed)) { + throw new Error(`DLQ record ${fileName} is not a JSON object.`); + } + return { + fileName, + filePath, + record: parsed, + summary: summarizeDlqRecord(fileName, parsed), + }; + }); + + records.sort((left, right) => { + const leftTs = Date.parse(left.summary.lastSeen); + const rightTs = Date.parse(right.summary.lastSeen); + if (!Number.isNaN(leftTs) && !Number.isNaN(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + return left.summary.eventId.localeCompare(right.summary.eventId); + }); + + return { workspaceDir, records }; +} + +function findMatchingRecords(records: DlqRecordEntry[], eventId: string): DlqRecordEntry[] { + const trimmed = eventId.trim(); + return records.filter( + (entry) => + entry.summary.eventId === trimmed || + path.basename(entry.fileName, path.extname(entry.fileName)) === trimmed + ); +} + +function isUnsupported(response: Response): boolean { + return response.status === 404 || response.status === 405 || response.status === 501; +} + +async function requestCloudDlq( + deps: DlqDependencies, + path: string, + options?: { apiUrl?: string; method?: 'GET' | 'POST' | 'DELETE' } +): Promise<{ response: Response; payload: unknown }> { + const auth = await deps.ensureAuthenticated(options?.apiUrl || deps.defaultCloudUrl); + const result = await deps.authorizedApiFetch(auth, path, { method: options?.method ?? 'GET' }); + let payload: unknown = null; + try { + payload = await result.response.json(); + } catch { + payload = null; + } + return { response: result.response, payload }; +} + +async function loadRemoteWorkspaceRecords( + deps: DlqDependencies, + workspace: string, + apiUrl?: string +): Promise { + const encodedWorkspace = encodeURIComponent(assertWorkspaceName(workspace)); + const listing = await requestCloudDlq(deps, `/api/v1/workspaces/${encodedWorkspace}/dlq`, { apiUrl }).catch( + () => null + ); + if (!listing || isUnsupported(listing.response)) { + return null; + } + if (!listing.response.ok) { + throw new Error(`Cloud DLQ list failed with HTTP ${listing.response.status}`); + } + + const items = readObject((listing.payload as JsonObject | null)?.data)?.items; + if (!Array.isArray(items)) { + return null; + } + + const records = await Promise.all( + items.map(async (entry, index) => { + const eventId = isObject(entry) ? readString(entry.eventId, entry.event_id) : undefined; + if (!eventId) { + throw new Error(`Cloud DLQ item ${index} is missing eventId`); + } + const detail = await requestCloudDlq( + deps, + `/api/v1/workspaces/${encodedWorkspace}/dlq/${encodeURIComponent(eventId)}`, + { apiUrl } + ); + const record = readObject((detail.payload as JsonObject | null)?.data); + if (!detail.response.ok || !record) { + throw new Error(`Cloud DLQ inspect failed for ${eventId}`); + } + return { + fileName: `${eventId}.json`, + filePath: '', + record, + summary: summarizeDlqRecord(`${eventId}.json`, record), + } satisfies DlqRecordEntry; + }) + ); + + records.sort((left, right) => { + const leftTs = Date.parse(left.summary.lastSeen); + const rightTs = Date.parse(right.summary.lastSeen); + if (!Number.isNaN(leftTs) && !Number.isNaN(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + return left.summary.eventId.localeCompare(right.summary.eventId); + }); + + return records; +} + +async function replayRemoteRecord( + deps: DlqDependencies, + workspace: string, + eventId: string, + apiUrl?: string +): Promise { + const encodedWorkspace = encodeURIComponent(assertWorkspaceName(workspace)); + let response; + try { + response = await requestCloudDlq( + deps, + `/api/v1/workspaces/${encodedWorkspace}/dlq/${encodeURIComponent(eventId)}/replay`, + { apiUrl, method: 'POST' } + ); + } catch (error) { + const candidate = error as { response?: Response }; + if (candidate.response && isUnsupported(candidate.response)) { + return false; + } + throw error; + } + if (isUnsupported(response.response)) { + return false; + } + if (!response.response.ok) { + throw new Error(`Cloud DLQ replay failed with HTTP ${response.response.status}`); + } + return true; +} + +async function purgeRemoteWorkspace( + deps: DlqDependencies, + workspace: string, + apiUrl?: string +): Promise { + const encodedWorkspace = encodeURIComponent(assertWorkspaceName(workspace)); + let response; + try { + response = await requestCloudDlq(deps, `/api/v1/workspaces/${encodedWorkspace}/dlq`, { + apiUrl, + method: 'DELETE', + }); + } catch (error) { + const candidate = error as { response?: Response }; + if (candidate.response && isUnsupported(candidate.response)) { + return false; + } + throw error; + } + if (isUnsupported(response.response)) { + return false; + } + if (!response.response.ok) { + throw new Error(`Cloud DLQ purge failed with HTTP ${response.response.status}`); + } + return true; +} + +function resolveReplayRequest( + record: JsonObject, + env: NodeJS.ProcessEnv +): { + url: string; + method: string; + headers: Record; + body: string | undefined; +} { + const gateway = readObject(record.gateway); + const replay = readObject( + record.replay, + record.replay_request, + gateway?.replay, + gateway?.request, + record.request + ); + const baseUrl = readString( + env.RELAY_DLQ_GATEWAY_URL, + env.RELAY_GATEWAY_URL, + env.OPENCLAW_GATEWAY_URL, + record.gatewayUrl, + record.gateway_url + ); + const fullUrl = readString(replay?.url, record.replayUrl, record.replay_url); + const requestPath = readString(replay?.path, replay?.endpoint, record.replayPath, record.replay_path); + + let url: string | undefined; + if (fullUrl) { + url = fullUrl; + } else if (baseUrl && requestPath) { + url = new URL(requestPath, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); + } + + if (!url) { + throw new Error( + 'DLQ record does not include replay URL metadata. Add `replay.url` or provide RELAY_DLQ_GATEWAY_URL plus `replay.path`.' + ); + } + + const method = readString(replay?.method, record.replayMethod, record.replay_method) ?? 'POST'; + const headerObject = readObject(replay?.headers, record.replayHeaders, record.replay_headers) ?? {}; + const headers: Record = {}; + for (const [key, value] of Object.entries(headerObject)) { + if (typeof value === 'string') { + headers[key] = value; + } + } + + const rawBody = replay?.body ?? record.event ?? record; + let body: string | undefined; + if (method !== 'GET' && method !== 'HEAD') { + if (typeof rawBody === 'string') { + body = rawBody; + } else { + body = JSON.stringify(rawBody); + if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) { + headers['content-type'] = 'application/json'; + } + } + } + + return { url, method: method.toUpperCase(), headers, body }; +} + +async function replayRecord( + deps: DlqDependencies, + entry: DlqRecordEntry, + workspace: string +): Promise<{ status: number; url: string }> { + const request = resolveReplayRequest( + { + workspace, + ...entry.record, + event: entry.record.event, + }, + deps.env + ); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + + let response: Response; + try { + response = await deps.fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Replay timed out for ${entry.summary.eventId} after 30000ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + const detail = await response.text().catch(() => response.statusText); + throw new Error( + `Replay failed for ${entry.summary.eventId}: HTTP ${response.status}${detail ? ` ${detail}` : ''}` + ); + } + + return { status: response.status, url: request.url }; +} + +export function registerDlqCommands(program: Command, overrides: Partial = {}): void { + const deps = withDefaults(overrides); + const dlq = program.command('dlq').description('Inspect and manage dead-letter queue records'); + + dlq + .command('list') + .description('List DLQ records for a workspace') + .requiredOption('--workspace ', 'Workspace name') + .option('--api-url ', 'Cloud API base URL') + .action(async (options: { workspace: string; apiUrl?: string }) => { + try { + const records = + (await loadRemoteWorkspaceRecords(deps, options.workspace, options.apiUrl).catch(() => null)) ?? + loadWorkspaceRecords(deps, options.workspace).records; + if (records.length === 0) { + deps.log(`No DLQ records found for workspace "${options.workspace}".`); + return; + } + + for (const entry of records) { + deps.log( + `${entry.summary.eventId} | ${entry.summary.type} | attempts=${entry.summary.attempts} | first=${entry.summary.firstSeen} | last=${entry.summary.lastSeen} | error=${entry.summary.error}` + ); + } + } catch (err: any) { + deps.error(`Failed to list DLQ records: ${err?.message || String(err)}`); + deps.exit(1); + } + }); + + dlq + .command('inspect') + .description('Print the full DLQ record for an event') + .requiredOption('--workspace ', 'Workspace name') + .option('--api-url ', 'Cloud API base URL') + .argument('', 'Event id to inspect') + .action(async (eventId: string, options: { workspace: string; apiUrl?: string }) => { + try { + const records = + (await loadRemoteWorkspaceRecords(deps, options.workspace, options.apiUrl).catch(() => null)) ?? + loadWorkspaceRecords(deps, options.workspace).records; + const matches = findMatchingRecords(records, eventId); + if (matches.length === 0) { + throw new Error(`No DLQ record found for event ${eventId}.`); + } + if (matches.length > 1) { + throw new Error( + `Multiple DLQ records found for event ${eventId}; inspect the files under _dlq manually.` + ); + } + + deps.log(JSON.stringify(matches[0].record, null, 2)); + } catch (err: any) { + deps.error(`Failed to inspect DLQ record: ${err?.message || String(err)}`); + deps.exit(1); + } + }); + + dlq + .command('replay') + .description('Replay one or more DLQ records into the gateway') + .requiredOption('--workspace ', 'Workspace name') + .option('--api-url ', 'Cloud API base URL') + .option('--all', 'Replay every DLQ record in the workspace') + .argument('[event-id]', 'Event id to replay') + .action( + async (eventId: string | undefined, options: { workspace: string; all?: boolean; apiUrl?: string }) => { + try { + const remoteRecords = await loadRemoteWorkspaceRecords( + deps, + options.workspace, + options.apiUrl + ).catch(() => null); + const records = remoteRecords ?? loadWorkspaceRecords(deps, options.workspace).records; + if (records.length === 0) { + deps.log(`No DLQ records found for workspace "${options.workspace}".`); + return; + } + + let targets: DlqRecordEntry[]; + if (options.all) { + targets = records; + } else if (eventId) { + targets = findMatchingRecords(records, eventId); + } else { + throw new InvalidArgumentError('Provide or pass --all.'); + } + + if (targets.length === 0) { + throw new Error(`No DLQ record found for event ${eventId}.`); + } + + for (const entry of targets) { + if ( + remoteRecords && + (await replayRemoteRecord(deps, options.workspace, entry.summary.eventId, options.apiUrl)) + ) { + deps.log(`Replayed ${entry.summary.eventId} via cloud workspace API.`); + continue; + } + const result = await replayRecord(deps, entry, options.workspace); + deps.log(`Replayed ${entry.summary.eventId} -> ${result.url} (${result.status})`); + } + } catch (err: any) { + deps.error(`Failed to replay DLQ record: ${err?.message || String(err)}`); + deps.exit(1); + } + } + ); + + dlq + .command('purge') + .description('Delete DLQ records for a workspace') + .requiredOption('--workspace ', 'Workspace name') + .option('--api-url ', 'Cloud API base URL') + .option('--older-than ', 'Only purge records older than a duration', parseDurationToMs) + .action(async (options: { workspace: string; olderThan?: number; apiUrl?: string }) => { + try { + if (options.olderThan === undefined) { + const purgedRemotely = await purgeRemoteWorkspace(deps, options.workspace, options.apiUrl).catch( + () => false + ); + if (purgedRemotely) { + deps.log(`Purged DLQ records from workspace "${options.workspace}" via cloud workspace API.`); + return; + } + } + + const { records } = loadWorkspaceRecords(deps, options.workspace); + if (records.length === 0) { + deps.log(`No DLQ records found for workspace "${options.workspace}".`); + return; + } + + const cutoff = typeof options.olderThan === 'number' ? deps.now() - options.olderThan : undefined; + const targets = records.filter((entry) => { + if (cutoff === undefined) return true; + const ts = Date.parse(entry.summary.lastSeen); + return !Number.isNaN(ts) && ts <= cutoff; + }); + + for (const entry of targets) { + deps.fs.unlinkSync(entry.filePath); + } + + deps.log(`Purged ${targets.length} DLQ record(s) from workspace "${options.workspace}".`); + } catch (err: any) { + deps.error(`Failed to purge DLQ records: ${err?.message || String(err)}`); + deps.exit(1); + } + }); +} diff --git a/src/cli/commands/proactive-bootstrap.test.ts b/src/cli/commands/proactive-bootstrap.test.ts new file mode 100644 index 000000000..3ed0b5347 --- /dev/null +++ b/src/cli/commands/proactive-bootstrap.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { Command } from 'commander'; +import { describe, it, vi, beforeEach } from 'vitest'; + +const { + createWorkspaceMock, + defaultApiUrlMock, + ensureAuthenticatedMock, + issueWorkspaceTokenMock, + readStoredAuthMock, +} = vi.hoisted(() => ({ + createWorkspaceMock: vi.fn(), + defaultApiUrlMock: vi.fn(() => 'https://cloud.test'), + ensureAuthenticatedMock: vi.fn(), + issueWorkspaceTokenMock: vi.fn(), + readStoredAuthMock: vi.fn(), +})); + +vi.mock('@agent-relay/cloud', () => ({ + REFRESH_WINDOW_MS: 5 * 60_000, + createWorkspace: (...args: unknown[]) => createWorkspaceMock(...args), + defaultApiUrl: () => defaultApiUrlMock(), + ensureAuthenticated: (...args: unknown[]) => ensureAuthenticatedMock(...args), + issueWorkspaceToken: (...args: unknown[]) => issueWorkspaceTokenMock(...args), + readStoredAuth: (...args: unknown[]) => readStoredAuthMock(...args), +})); + +import { + registerProactiveBootstrapCommands, + type ProactiveBootstrapDependencies, +} from './proactive-bootstrap.js'; + +function createHarness() { + const lines: string[] = []; + const errors: string[] = []; + const program = new Command(); + registerProactiveBootstrapCommands(program, { + log: (...args: unknown[]) => { + lines.push(args.map((value) => String(value)).join(' ')); + }, + error: (...args: unknown[]) => { + errors.push(args.map((value) => String(value)).join(' ')); + }, + exit: ((code: number) => { + throw new Error(`exit:${code}`); + }) as ProactiveBootstrapDependencies['exit'], + }); + + return { program, lines, errors }; +} + +describe('proactive bootstrap commands', () => { + beforeEach(() => { + createWorkspaceMock.mockReset(); + defaultApiUrlMock.mockReset(); + defaultApiUrlMock.mockReturnValue('https://cloud.test'); + ensureAuthenticatedMock.mockReset(); + issueWorkspaceTokenMock.mockReset(); + readStoredAuthMock.mockReset(); + }); + + it('prints RELAY_API_KEY output for tokens issue', async () => { + issueWorkspaceTokenMock.mockResolvedValue({ + key: 'relay_ws_live_support', + workspaceToken: { workspaceId: 'support', kind: 'workspace_token' }, + }); + + const { program, lines, errors } = createHarness(); + await program.parseAsync(['node', 'agent-relay', 'tokens', 'issue', '--workspace', 'support']); + + assert.deepEqual(errors, []); + assert.deepEqual(lines, [ + 'RELAY_API_KEY=relay_ws_live_support', + 'Export this value before starting SDK-backed proactive runtime commands.', + ]); + assert.deepEqual(issueWorkspaceTokenMock.mock.calls, [['support', { apiUrl: undefined }]]); + }); + + it('does not print an extra success line after fresh login', async () => { + readStoredAuthMock.mockResolvedValue(null); + ensureAuthenticatedMock.mockResolvedValue({ + apiUrl: 'https://cloud.test', + accessToken: 'access_token_test', + refreshToken: 'refresh_token_test', + accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + + const { program, lines, errors } = createHarness(); + await program.parseAsync(['node', 'agent-relay', 'login', '--api-url', 'https://cloud.test', '--force']); + + assert.deepEqual(errors, []); + assert.deepEqual(lines, []); + assert.deepEqual(ensureAuthenticatedMock.mock.calls, [['https://cloud.test', { force: true }]]); + }); + + it('surfaces workspace-create failures without a raw stack trace', async () => { + createWorkspaceMock.mockRejectedValue(new Error('Workspace name is invalid')); + + const { program, errors } = createHarness(); + await assert.rejects( + program.parseAsync(['node', 'agent-relay', 'workspaces', 'create', '!!!']), + /exit:1/ + ); + + assert.deepEqual(errors, ['Workspace name is invalid']); + }); +}); diff --git a/src/cli/commands/proactive-bootstrap.ts b/src/cli/commands/proactive-bootstrap.ts new file mode 100644 index 000000000..401441390 --- /dev/null +++ b/src/cli/commands/proactive-bootstrap.ts @@ -0,0 +1,171 @@ +import { Command } from 'commander'; +import { track } from '@agent-relay/telemetry'; +import { + REFRESH_WINDOW_MS, + createWorkspace, + defaultApiUrl, + ensureAuthenticated, + issueWorkspaceToken, + readStoredAuth, + type WorkspaceCreateResponse, + type WorkspaceTokenIssueResponse, +} from '@agent-relay/cloud'; + +import { defaultExit } from '../lib/exit.js'; +import { errorClassName } from '../lib/telemetry-helpers.js'; + +type ExitFn = (code: number) => never; + +export interface ProactiveBootstrapDependencies { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: ExitFn; +} + +function withDefaults( + overrides: Partial = {} +): ProactiveBootstrapDependencies { + return { + log: (...args: unknown[]) => console.log(...args), + error: (...args: unknown[]) => console.error(...args), + exit: defaultExit, + ...overrides, + }; +} + +function printWorkspaceCreateResult( + result: WorkspaceCreateResponse, + log: (...args: unknown[]) => void +): void { + log(`Workspace created: ${result.workspaceId}`); + if (result.name) { + log(`Name: ${result.name}`); + } + if (result.relayfileUrl) { + log(`Relayfile URL: ${result.relayfileUrl}`); + } + if (result.relaycronUrl) { + log(`Relaycron URL: ${result.relaycronUrl}`); + } + if (result.relaycastUrl) { + log(`Relaycast URL: ${result.relaycastUrl}`); + } + if (result.relayauthUrl) { + log(`Relayauth URL: ${result.relayauthUrl}`); + } + if (result.joinCommand) { + log(`Join command: ${result.joinCommand}`); + } +} + +function printWorkspaceTokenResult( + result: WorkspaceTokenIssueResponse, + log: (...args: unknown[]) => void +): void { + log(`RELAY_API_KEY=${result.key}`); + log('Export this value before starting SDK-backed proactive runtime commands.'); +} + +export function registerProactiveBootstrapCommands( + program: Command, + overrides: Partial = {} +): void { + const deps = withDefaults(overrides); + const runWorkspaceCreate = async ( + name: string, + options: { apiUrl?: string; json?: boolean } + ): Promise => { + try { + const result = await createWorkspace(name, { apiUrl: options.apiUrl }); + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + } else { + printWorkspaceCreateResult(result, deps.log); + } + } catch (err) { + deps.error(err instanceof Error ? err.message : String(err)); + deps.exit(1); + } + }; + + program + .command('login') + .description('Authenticate with Agent Relay Cloud via browser') + .option('--api-url ', 'Cloud API base URL') + .option('--force', 'Force re-authentication even if already logged in') + .action(async (options: { apiUrl?: string; force?: boolean }) => { + const started = Date.now(); + let success = false; + let errorClass: string | undefined; + try { + const apiUrl = options.apiUrl || defaultApiUrl(); + + if (!options.force) { + const existing = await readStoredAuth(); + if (existing && existing.apiUrl === apiUrl) { + const expiresAt = Date.parse(existing.accessTokenExpiresAt); + if (!Number.isNaN(expiresAt) && expiresAt - Date.now() > REFRESH_WINDOW_MS) { + deps.log(`Already logged in to ${existing.apiUrl}`); + success = true; + return; + } + } + } + + await ensureAuthenticated(apiUrl, { force: options.force }); + success = true; + } catch (err) { + errorClass = errorClassName(err); + throw err; + } finally { + track('cloud_auth', { + action: 'login', + success, + duration_ms: Date.now() - started, + ...(errorClass ? { error_class: errorClass } : {}), + }); + } + }); + + program + .command('init ') + .description('Create a proactive-runtime workspace through the canonical bootstrap path') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Print raw JSON response', false) + .action(async (name: string, options: { apiUrl?: string; json?: boolean }) => { + await runWorkspaceCreate(name, options); + }); + + const workspaces = program.command('workspaces').description('Manage proactive-runtime workspaces'); + + workspaces + .command('create ') + .description('Create a proactive-runtime workspace') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Print raw JSON response', false) + .action(async (name: string, options: { apiUrl?: string; json?: boolean }) => { + await runWorkspaceCreate(name, options); + }); + + const tokens = program.command('tokens').description('Issue proactive-runtime workspace tokens'); + + tokens + .command('issue') + .description('Issue a workspace token for RELAY_API_KEY') + .requiredOption('--workspace ', 'Workspace name or ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Print raw JSON response', false) + .action(async (options: { workspace: string; apiUrl?: string; json?: boolean }) => { + try { + const result = await issueWorkspaceToken(options.workspace, { apiUrl: options.apiUrl }); + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + } else { + printWorkspaceTokenResult(result, deps.log); + } + } catch (err) { + deps.error(err instanceof Error ? err.message : String(err)); + deps.exit(1); + } + }); +} diff --git a/src/cli/commands/relay-runtime.test.ts b/src/cli/commands/relay-runtime.test.ts new file mode 100644 index 000000000..115ce12c8 --- /dev/null +++ b/src/cli/commands/relay-runtime.test.ts @@ -0,0 +1,179 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerRelayRuntimeCommands, type RelayRuntimeDependencies } from './relay-runtime.js'; + +class ExitSignal extends Error { + constructor(public readonly code: number) { + super(`exit:${code}`); + } +} + +const createdTmpRoots: string[] = []; + +function createHarness(overrides: Partial = {}) { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-runtime-cli-')); + createdTmpRoots.push(tmpRoot); + const logs: string[] = []; + const errors: string[] = []; + + const exit = vi.fn((code: number) => { + throw new ExitSignal(code); + }) as unknown as RelayRuntimeDependencies['exit']; + + const deps: RelayRuntimeDependencies = { + cwd: () => tmpRoot, + fileExists: fs.existsSync, + readFile: (filePath, encoding = 'utf-8') => fs.readFileSync(filePath, encoding), + mkdir: (dirPath) => fs.promises.mkdir(dirPath, { recursive: true }), + writeFile: (filePath, contents) => fs.promises.writeFile(filePath, contents, 'utf-8'), + readSecretFromStdin: async () => undefined, + deploy: vi.fn(async () => ({ status: 'accepted', deploymentId: 'dep_123', workspaceId: 'ws_123' })), + listAgents: vi.fn(async () => []), + inspectAgent: vi.fn(async (agentId: string) => ({ id: agentId, status: 'connected' })), + undeployAgent: vi.fn(async (agentId: string) => ({ id: agentId, status: 'deleted' })), + createSecret: vi.fn(async (name: string) => ({ name, maskedValue: '****1234' })), + getSecret: vi.fn(async (name: string) => ({ name, maskedValue: '****1234' })), + deleteSecret: vi.fn(async (name: string) => ({ name })), + ensureAuthenticated: vi.fn(async () => ({ + apiUrl: 'https://cloud.test', + accessToken: 'token', + refreshToken: 'refresh', + accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(), + })), + authorizedApiFetch: vi.fn(async () => { + throw new Error('not used in this test'); + }), + createWebSocket: vi.fn(() => ({ + on: () => undefined, + close: () => undefined, + })), + defaultCloudUrl: 'https://cloud.test', + log: (...args: unknown[]) => logs.push(args.join(' ')), + error: (...args: unknown[]) => errors.push(args.join(' ')), + exit, + ...overrides, + }; + + const program = new Command(); + program.name('relay'); + registerRelayRuntimeCommands(program, deps); + + return { program, deps, tmpRoot, logs, errors }; +} + +async function runCommand(program: Command, args: string[]): Promise { + try { + await program.parseAsync(args, { from: 'user' }); + return undefined; + } catch (error) { + if (error instanceof ExitSignal) { + return error.code; + } + throw error; + } +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const tmpRoot of createdTmpRoots.splice(0)) { + try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +}); + +describe('registerRelayRuntimeCommands', () => { + it('registers relay-only proactive runtime commands', () => { + const { program } = createHarness(); + const commandNames = program.commands.map((command) => command.name()); + + expect(commandNames).toEqual(expect.arrayContaining(['init', 'deploy', 'logs', 'agents', 'secrets'])); + + const agents = program.commands.find((command) => command.name() === 'agents'); + expect(agents?.commands.map((command) => command.name())).toEqual( + expect.arrayContaining(['list', 'inspect', 'undeploy']) + ); + }); + + it('scaffolds a starter project with package, entrypoint, env template, and readme', async () => { + const { program, tmpRoot } = createHarness(); + + const exitCode = await runCommand(program, ['init', 'starter-app']); + + expect(exitCode).toBeUndefined(); + const targetDir = path.join(tmpRoot, 'starter-app'); + expect(fs.existsSync(path.join(targetDir, 'package.json'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, 'src', 'agent.ts'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, '.env.example'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, 'README.md'))).toBe(true); + expect(fs.readFileSync(path.join(targetDir, 'src', 'agent.ts'), 'utf-8')).toContain('await agent({'); + }); + + it('deploys a proactive entrypoint', async () => { + const { program, deps, tmpRoot, logs } = createHarness(); + const entry = path.join(tmpRoot, 'src', 'agent.ts'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, 'export {};\n', 'utf-8'); + + const exitCode = await runCommand(program, ['deploy', 'src/agent.ts', '--name', 'support-agent']); + + expect(exitCode).toBeUndefined(); + expect(deps.deploy).toHaveBeenCalledWith( + { entrypoint: 'src/agent.ts', source: 'export {};\n' }, + { apiUrl: undefined, name: 'support-agent', watch: undefined } + ); + expect(logs.join('\n')).toContain('Deployment: dep_123'); + }); + + it('lists deployed proactive agents', async () => { + const { program, deps, logs } = createHarness({ + listAgents: vi.fn(async () => [ + { + id: 'agent_1', + displayName: 'SupportAgent', + harness: 'codex', + defaultModel: 'gpt-5.4', + status: 'connected', + }, + ]), + }); + + const exitCode = await runCommand(program, ['agents', 'list']); + + expect(exitCode).toBeUndefined(); + expect(deps.listAgents).toHaveBeenCalledWith({ apiUrl: undefined }); + expect(logs.join('\n')).toContain('SupportAgent'); + }); + + it('creates a workspace secret from --value', async () => { + const { program, deps, logs } = createHarness(); + + const exitCode = await runCommand(program, [ + 'secrets', + 'create', + 'anthropic-api-key', + '--workspace', + 'support', + '--value', + 'secret-value', + ]); + + expect(exitCode).toBeUndefined(); + expect(deps.createSecret).toHaveBeenCalledWith('anthropic-api-key', 'secret-value', { + workspace: 'support', + apiUrl: undefined, + }); + expect(logs.join('\n')).toContain('Stored secret: anthropic-api-key'); + }); +}); diff --git a/src/cli/commands/relay-runtime.ts b/src/cli/commands/relay-runtime.ts new file mode 100644 index 000000000..c0c466a9f --- /dev/null +++ b/src/cli/commands/relay-runtime.ts @@ -0,0 +1,627 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { Command } from 'commander'; +import WebSocket from 'ws'; +import { + authorizedApiFetch, + createWorkspaceSecret, + defaultApiUrl, + deleteWorkspaceSecret, + deployProactiveAgent, + ensureAuthenticated, + getWorkspaceSecret, + inspectProactiveAgent, + listProactiveAgents, + undeployProactiveAgent, + type ProactiveAgentRecord, + type ProactiveDeploymentResponse, + type StoredAuth, + type WorkspaceSecretRecord, +} from '@agent-relay/cloud'; + +import { defaultExit } from '../lib/exit.js'; + +type ExitFn = (code: number) => never; + +type WebSocketFactory = ( + url: string, + options?: { + headers?: Record; + } +) => { + on(event: string, listener: (...args: any[]) => void): unknown; + close(): void; +}; + +export interface RelayRuntimeDependencies { + cwd: () => string; + fileExists: (filePath: string) => boolean; + readFile: (filePath: string, encoding?: BufferEncoding) => string; + mkdir: (dirPath: string) => Promise; + writeFile: (filePath: string, contents: string) => Promise; + readSecretFromStdin: () => Promise; + deploy: ( + input: { entrypoint: string; source: string }, + options?: { apiUrl?: string; name?: string; watch?: boolean } + ) => Promise; + listAgents: (options?: { apiUrl?: string }) => Promise; + inspectAgent: (agentId: string, options?: { apiUrl?: string }) => Promise; + undeployAgent: (agentId: string, options?: { apiUrl?: string }) => Promise; + createSecret: ( + name: string, + value: string, + options: { apiUrl?: string; workspace: string } + ) => Promise; + getSecret: ( + name: string, + options: { apiUrl?: string; workspace: string } + ) => Promise; + deleteSecret: ( + name: string, + options: { apiUrl?: string; workspace: string } + ) => Promise; + ensureAuthenticated: (apiUrl: string, options?: { force?: boolean }) => Promise; + authorizedApiFetch: typeof authorizedApiFetch; + createWebSocket: WebSocketFactory; + defaultCloudUrl: string; + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: ExitFn; +} + +type JsonRecord = Record; + +function withDefaults(overrides: Partial = {}): RelayRuntimeDependencies { + return { + cwd: () => process.cwd(), + fileExists: fs.existsSync, + readFile: (filePath, encoding = 'utf-8') => fs.readFileSync(filePath, encoding), + mkdir: async (dirPath) => { + await fsp.mkdir(dirPath, { recursive: true }); + }, + writeFile: (filePath, contents) => fsp.writeFile(filePath, contents, 'utf-8'), + readSecretFromStdin: async () => { + if (process.stdin.isTTY) return undefined; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const value = Buffer.concat(chunks).toString('utf-8').trim(); + return value.length > 0 ? value : undefined; + }, + deploy: (input, options) => deployProactiveAgent(input, options), + listAgents: (options) => listProactiveAgents(options), + inspectAgent: (agentId, options) => inspectProactiveAgent(agentId, options), + undeployAgent: (agentId, options) => undeployProactiveAgent(agentId, options), + createSecret: (name, value, options) => createWorkspaceSecret(name, value, options), + getSecret: (name, options) => getWorkspaceSecret(name, options), + deleteSecret: (name, options) => deleteWorkspaceSecret(name, options), + ensureAuthenticated, + authorizedApiFetch, + createWebSocket: (url, options) => new WebSocket(url, options), + defaultCloudUrl: overrides.defaultCloudUrl ?? defaultApiUrl(), + log: (...args: unknown[]) => console.log(...args), + error: (...args: unknown[]) => console.error(...args), + exit: defaultExit, + ...overrides, + }; +} + +function isObject(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readString(payload: JsonRecord, key: string): string | undefined { + const value = payload[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function extractLogLine(payload: unknown): string | null { + if (typeof payload === 'string') { + return payload; + } + if (!isObject(payload)) { + return null; + } + + return ( + readString(payload, 'message') ?? + readString(payload, 'content') ?? + readString(payload, 'line') ?? + readString(payload, 'text') ?? + (payload.event && typeof payload.event === 'string' ? payload.event : null) ?? + JSON.stringify(payload) + ); +} + +function renderAgentSummary(agent: ProactiveAgentRecord): string { + const label = agent.displayName ?? agent.name ?? agent.id; + const status = agent.status ?? 'unknown'; + const harness = agent.harness ?? '-'; + const model = agent.defaultModel ?? '-'; + return `${label} ${status} ${harness} ${model} ${agent.id}`; +} + +function normalizeProjectName(name: string): string { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'relay-agent' + ); +} + +function buildPackageJson(projectName: string): string { + return ( + JSON.stringify( + { + name: normalizeProjectName(projectName), + private: true, + type: 'module', + scripts: { + deploy: 'relay deploy src/agent.ts', + }, + dependencies: { + '@agent-relay/agent': 'latest', + }, + }, + null, + 2 + ) + '\n' + ); +} + +function buildAgentSource(projectName: string): string { + return `import { agent } from "@agent-relay/agent"; + +await agent({ + workspace: process.env.RELAY_WORKSPACE ?? "${normalizeProjectName(projectName)}", + schedule: "0 * * * *", + onEvent: async (_ctx, event) => { + console.log("received event", event.type); + }, +}); +`; +} + +function buildEnvTemplate(projectName: string): string { + return `# Set this to the workspace you want the agent to join. +RELAY_WORKSPACE=${normalizeProjectName(projectName)} +`; +} + +function buildReadme(projectName: string): string { + return `# ${projectName} + +Minimal proactive agent scaffold for \`relay deploy\`. + +## Files + +- \`src/agent.ts\` is the entrypoint deployed by the relay CLI. +- \`.env.example\` shows the workspace variable the agent reads at runtime. + +## Next steps + +1. Run \`npm install\`. +2. Run \`relay login\` if you have not linked this machine yet. +3. Create or choose a workspace, then update \`RELAY_WORKSPACE\`. +4. Deploy with \`relay deploy src/agent.ts --name ${normalizeProjectName(projectName)}\`. +`; +} + +async function streamEventSource( + response: Response, + follow: boolean, + deps: RelayRuntimeDependencies +): Promise { + if (!response.body) { + deps.error('Log stream response did not include a body.'); + deps.exit(1); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let printed = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trim()) + .filter(Boolean); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + const rendered = extractLogLine(parsed); + if (rendered) deps.log(rendered); + } catch { + deps.log(line); + } + printed = true; + if (!follow) { + return; + } + } + } + } + + if (!printed && buffer.trim()) { + deps.log(buffer.trim()); + } + } catch (error) { + deps.error(error instanceof Error ? error.message : String(error)); + deps.exit(1); + } finally { + try { + reader.releaseLock(); + } catch { + // ignore cleanup errors + } + } +} + +async function streamWebSocketLogs( + wsUrl: string, + auth: StoredAuth, + follow: boolean, + deps: RelayRuntimeDependencies +): Promise { + await new Promise((resolve, reject) => { + const socket = deps.createWebSocket(wsUrl, { + headers: { + authorization: `Bearer ${auth.accessToken}`, + }, + }); + let settled = false; + let printed = false; + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + socket.close(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + socket.on('message', (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString('utf-8') : String(raw); + try { + const parsed = JSON.parse(text); + const line = extractLogLine(parsed); + if (line) deps.log(line); + } catch { + deps.log(text); + } + printed = true; + if (!follow) { + finish(); + } + }); + + socket.on('error', (error) => { + finish(error instanceof Error ? error : new Error(String(error))); + }); + + socket.on('close', () => { + if (!printed && !follow) { + deps.log('(no log events received)'); + } + finish(); + }); + }); +} + +function buildLogStreamCandidates(workspace: string, agent: string | undefined): string[] { + const encodedWorkspace = encodeURIComponent(workspace); + const search = agent ? `?agent=${encodeURIComponent(agent)}` : ''; + return [ + `/api/v1/workspaces/${encodedWorkspace}/logs/stream${search}`, + `/api/v1/workspaces/${encodedWorkspace}/events/stream${search}`, + `/api/v1/proactive/workspaces/${encodedWorkspace}/logs${search}`, + ]; +} + +async function runLogsCommand( + options: { + workspace: string; + agent?: string; + follow?: boolean; + apiUrl?: string; + }, + deps: RelayRuntimeDependencies +): Promise { + const apiUrl = options.apiUrl || deps.defaultCloudUrl; + let auth = await deps.ensureAuthenticated(apiUrl); + + for (const endpoint of buildLogStreamCandidates(options.workspace, options.agent)) { + const { response, auth: nextAuth } = await deps.authorizedApiFetch(auth, endpoint, { method: 'GET' }); + auth = nextAuth; + + if ([404, 405, 501].includes(response.status)) { + continue; + } + + if (!response.ok) { + let detail = response.statusText; + try { + const payload = (await response.json()) as JsonRecord | null; + detail = + (payload && (readString(payload, 'error') ?? readString(payload, 'message') ?? detail)) || detail; + } catch { + // ignore parse failure + } + throw new Error(`Logs stream failed at ${endpoint}: ${response.status} ${detail}`.trim()); + } + + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('text/event-stream')) { + await streamEventSource(response, Boolean(options.follow), deps); + return; + } + + if (contentType.includes('application/json')) { + const payload = (await response.json().catch(() => null)) as JsonRecord | null; + if (payload) { + const wsUrl = + readString(payload, 'wsUrl') ?? readString(payload, 'streamUrl') ?? readString(payload, 'url'); + if (wsUrl) { + await streamWebSocketLogs(wsUrl, auth, Boolean(options.follow), deps); + return; + } + + const directLine = extractLogLine(payload); + if (directLine) { + deps.log(directLine); + return; + } + } + } + + const text = await response.text(); + if (text.trim()) { + deps.log(text.trimEnd()); + return; + } + } + + throw new Error('Workspace log streaming is not supported by the configured cloud API.'); +} + +export function registerRelayRuntimeCommands( + program: Command, + overrides: Partial = {} +): void { + if (program.name() !== 'relay') { + return; + } + + const deps = withDefaults(overrides); + + program + .command('init [name]') + .description('Scaffold a minimal proactive agent project') + .option('--force', 'Overwrite target files if they already exist') + .action(async (name: string | undefined, options: { force?: boolean }) => { + const cwd = deps.cwd(); + const projectName = (name?.trim() || path.basename(cwd)).trim() || 'relay-agent'; + const targetDir = name?.trim() ? path.resolve(cwd, name.trim()) : cwd; + const srcDir = path.join(targetDir, 'src'); + const files = [ + path.join(targetDir, 'package.json'), + path.join(srcDir, 'agent.ts'), + path.join(targetDir, '.env.example'), + path.join(targetDir, 'README.md'), + ]; + + if (!options.force) { + const existing = files.filter((filePath) => deps.fileExists(filePath)); + if (existing.length > 0) { + deps.error(`Refusing to overwrite existing files in ${targetDir}`); + existing.forEach((filePath) => deps.error(`- ${path.relative(cwd, filePath) || filePath}`)); + deps.exit(1); + return; + } + } + + await deps.mkdir(srcDir); + await deps.writeFile(path.join(targetDir, 'package.json'), buildPackageJson(projectName)); + await deps.writeFile(path.join(srcDir, 'agent.ts'), buildAgentSource(projectName)); + await deps.writeFile(path.join(targetDir, '.env.example'), buildEnvTemplate(projectName)); + await deps.writeFile(path.join(targetDir, 'README.md'), buildReadme(projectName)); + + deps.log(`Scaffolded proactive agent project in ${targetDir}`); + }); + + program + .command('deploy ') + .description('Deploy a proactive agent entrypoint to the managed runtime') + .option('--name ', 'Deployment name override') + .option('--watch', 'Follow the workspace log stream after deploy') + .option('--api-url ', 'Cloud API base URL') + .action(async (filePath: string, options: { name?: string; watch?: boolean; apiUrl?: string }) => { + const resolvedPath = path.resolve(deps.cwd(), filePath); + if (!deps.fileExists(resolvedPath)) { + deps.error(`Entrypoint not found: ${resolvedPath}`); + deps.exit(1); + return; + } + + const source = await Promise.resolve(deps.readFile(resolvedPath, 'utf-8')); + const result = await deps.deploy( + { entrypoint: filePath, source }, + { apiUrl: options.apiUrl, name: options.name, watch: options.watch } + ); + + deps.log(`Deployment status: ${result.status ?? 'accepted'}`); + if (result.deploymentId) deps.log(`Deployment: ${result.deploymentId}`); + if (result.agentId) deps.log(`Agent: ${result.agentId}`); + if (result.workspaceId) deps.log(`Workspace: ${result.workspaceId}`); + if (result.dashboardUrl) deps.log(`Dashboard: ${result.dashboardUrl}`); + + if (options.watch) { + if (!result.workspaceId) { + deps.error('Watch requested but the deploy response did not include a workspace id.'); + deps.exit(1); + return; + } + await runLogsCommand( + { + workspace: result.workspaceId, + agent: result.agentId, + follow: true, + apiUrl: options.apiUrl, + }, + deps + ); + } + }); + + program + .command('logs') + .description('Tail the proactive runtime workspace log stream') + .requiredOption('--workspace ', 'Workspace id or slug') + .option('--agent ', 'Filter logs to a specific agent') + .option('--follow', 'Keep streaming until interrupted') + .option('--api-url ', 'Cloud API base URL') + .action(async (options: { workspace: string; agent?: string; follow?: boolean; apiUrl?: string }) => { + await runLogsCommand(options, deps); + }); + + const agentsCommand = + program.commands.find((command) => command.name() === 'agents') ?? + program.command('agents').description('Manage deployed proactive agents'); + (agentsCommand as Command & { _hidden?: boolean })._hidden = false; + + agentsCommand + .command('list') + .description('List deployed proactive agents') + .option('--json', 'Output raw JSON') + .option('--api-url ', 'Cloud API base URL') + .action(async (options: { json?: boolean; apiUrl?: string }) => { + const agents = await deps.listAgents({ apiUrl: options.apiUrl }); + if (options.json) { + deps.log(JSON.stringify(agents, null, 2)); + return; + } + + if (agents.length === 0) { + deps.log('No deployed proactive agents found.'); + return; + } + + deps.log('NAME STATUS HARNESS MODEL ID'); + agents.forEach((agent) => deps.log(renderAgentSummary(agent))); + }); + + agentsCommand + .command('inspect ') + .description('Inspect a deployed proactive agent') + .option('--json', 'Output raw JSON') + .option('--api-url ', 'Cloud API base URL') + .action(async (agentId: string, options: { json?: boolean; apiUrl?: string }) => { + const agent = await deps.inspectAgent(agentId, { apiUrl: options.apiUrl }); + if (options.json) { + deps.log(JSON.stringify(agent, null, 2)); + return; + } + + deps.log(`Agent: ${agent.displayName ?? agent.name ?? agent.id}`); + deps.log(`Id: ${agent.id}`); + deps.log(`Status: ${agent.status ?? 'unknown'}`); + deps.log(`Harness: ${agent.harness ?? '-'}`); + deps.log(`Model: ${agent.defaultModel ?? '-'}`); + if (agent.lastError) deps.log(`Last error: ${agent.lastError}`); + }); + + agentsCommand + .command('undeploy ') + .description('Undeploy a proactive agent') + .option('--json', 'Output raw JSON') + .option('--api-url ', 'Cloud API base URL') + .action(async (agentId: string, options: { json?: boolean; apiUrl?: string }) => { + const agent = await deps.undeployAgent(agentId, { apiUrl: options.apiUrl }); + if (options.json) { + deps.log(JSON.stringify(agent, null, 2)); + return; + } + + deps.log(`Undeployed agent: ${agent.displayName ?? agent.name ?? agent.id}`); + }); + + const secrets = program.command('secrets').description('Manage proactive runtime workspace secrets'); + + secrets + .command('create ') + .description('Create or update a workspace secret') + .requiredOption('--workspace ', 'Workspace id or slug') + .option('--value ', 'Secret value (omit to read from stdin)') + .option('--api-url ', 'Cloud API base URL') + .action(async (name: string, options: { workspace: string; value?: string; apiUrl?: string }) => { + const value = options.value ?? (await deps.readSecretFromStdin()); + if (!value) { + deps.error('Secret value required via --value or stdin.'); + deps.exit(1); + return; + } + + const secret = await deps.createSecret(name, value, { + workspace: options.workspace, + apiUrl: options.apiUrl, + }); + deps.log(`Stored secret: ${secret.name}`); + if (secret.maskedValue) deps.log(`Value: ${secret.maskedValue}`); + }); + + secrets + .command('get ') + .description('Read a workspace secret record') + .requiredOption('--workspace ', 'Workspace id or slug') + .option('--json', 'Output raw JSON') + .option('--api-url ', 'Cloud API base URL') + .action(async (name: string, options: { workspace: string; json?: boolean; apiUrl?: string }) => { + const secret = await deps.getSecret(name, { + workspace: options.workspace, + apiUrl: options.apiUrl, + }); + if (options.json) { + deps.log(JSON.stringify(secret, null, 2)); + return; + } + + deps.log(`Secret: ${secret.name}`); + if (secret.value) deps.log(`Value: ${secret.value}`); + if (secret.maskedValue) deps.log(`Masked: ${secret.maskedValue}`); + }); + + secrets + .command('delete ') + .description('Delete a workspace secret') + .requiredOption('--workspace ', 'Workspace id or slug') + .option('--api-url ', 'Cloud API base URL') + .action(async (name: string, options: { workspace: string; apiUrl?: string }) => { + const secret = await deps.deleteSecret(name, { + workspace: options.workspace, + apiUrl: options.apiUrl, + }); + deps.log(`Deleted secret: ${secret.name}`); + }); +} diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 8450b97ba..6b68e4bc6 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -277,15 +277,17 @@ function runTelemetryDefault(action: string | undefined, io: SetupIo): void { } export function registerSetupCommands(program: Command, overrides: Partial = {}): void { const deps = withDefaults(overrides); - program - .command('init', { hidden: true }) - .description('First-time setup wizard - start broker') - .option('-y, --yes', 'Accept all defaults (non-interactive)') - .option('--skip-broker', 'Skip broker startup prompt') - .addHelpText('after', '\nBREAKING CHANGE: daemon options were removed. Use broker terminology only.') - .action(async (options: RunInitOptions) => { - await deps.runInit(options); - }); + if (program.name() !== 'relay' && !program.commands.some((command) => command.name() === 'init')) { + program + .command('init', { hidden: true }) + .description('First-time setup wizard - start broker') + .option('-y, --yes', 'Accept all defaults (non-interactive)') + .option('--skip-broker', 'Skip broker startup prompt') + .addHelpText('after', '\nBREAKING CHANGE: daemon options were removed. Use broker terminology only.') + .action(async (options: RunInitOptions) => { + await deps.runInit(options); + }); + } program .command('setup', { hidden: true }) .description('Alias for "init" - first-time setup wizard') diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 244ccf49f..d626d6cb1 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -76,7 +76,7 @@ describeCli('CLI', () => { // Commander outputs help to stderr when no command is provided const output = stdout + stderr; expect(output).toContain('Usage:'); - }); + }, 15000); }); describe('agents', () => { diff --git a/tests/cli-tokens.test.ts b/tests/cli-tokens.test.ts new file mode 100644 index 000000000..62e35e548 --- /dev/null +++ b/tests/cli-tokens.test.ts @@ -0,0 +1,379 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { Command } from 'commander'; +import { test } from 'vitest'; + +import { AUTH_FILE_PATH } from '../packages/cloud/src/index.js'; +import { + registerProactiveBootstrapCommands, + type ProactiveBootstrapDependencies, +} from '../src/cli/commands/proactive-bootstrap.js'; + +function createHarness(overrides: Partial = {}) { + const deps: ProactiveBootstrapDependencies = { + log: () => {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(`exit:${code}`); + }) as ProactiveBootstrapDependencies['exit'], + ...overrides, + }; + + const program = new Command(); + registerProactiveBootstrapCommands(program, deps); + + return { program, deps }; +} + +function installEnvAuth(): () => void { + const original = { + CLOUD_API_URL: process.env.CLOUD_API_URL, + CLOUD_API_ACCESS_TOKEN: process.env.CLOUD_API_ACCESS_TOKEN, + CLOUD_API_REFRESH_TOKEN: process.env.CLOUD_API_REFRESH_TOKEN, + CLOUD_API_ACCESS_TOKEN_EXPIRES_AT: process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT, + }; + + process.env.CLOUD_API_URL = 'https://cloud.test'; + process.env.CLOUD_API_ACCESS_TOKEN = 'access_token_test'; + process.env.CLOUD_API_REFRESH_TOKEN = 'refresh_token_test'; + process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = new Date(Date.now() + 10 * 60_000).toISOString(); + + return () => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; +} + +function createLogCollector(): { + deps: Pick; + lines: string[]; + errors: string[]; +} { + const lines: string[] = []; + const errors: string[] = []; + return { + deps: { + log: (...args: unknown[]) => { + lines.push(args.map((value) => String(value)).join(' ')); + }, + error: (...args: unknown[]) => { + errors.push(args.map((value) => String(value)).join(' ')); + }, + }, + lines, + errors, + }; +} + +async function restoreAuthFile(previousContents: string | null): Promise { + if (previousContents === null) { + await fs.rm(AUTH_FILE_PATH, { force: true }); + return; + } + + await fs.writeFile(AUTH_FILE_PATH, previousContents, 'utf8'); +} + +test('tokens issue prints the issued workspace key by default', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { lines, errors } = collector; + const restoreEnv = installEnvAuth(); + const originalFetch = globalThis.fetch; + const requests: Array<{ + url: string; + method: string; + authorization: string | null; + body: { workspaceId: string; name: string }; + }> = []; + + globalThis.fetch = (async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init); + const body = (await request.json()) as { workspaceId: string; name: string }; + requests.push({ + url: request.url, + method: request.method, + authorization: request.headers.get('authorization'), + body, + }); + + return Response.json( + { + key: 'relay_ws_live_support', + workspaceToken: { + workspaceId: 'support', + kind: 'workspace_token', + }, + }, + { status: 200 } + ); + }) as typeof globalThis.fetch; + + try { + await program.parseAsync(['node', 'agent-relay', 'tokens', 'issue', '--workspace', 'support']); + + assert.deepEqual(errors, []); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, 'https://cloud.test/api/v1/workspaces/support/tokens/workspace'); + assert.equal(requests[0]?.method, 'POST'); + assert.equal(requests[0]?.authorization, 'Bearer access_token_test'); + assert.deepEqual(requests[0]?.body, { + workspaceId: 'support', + name: 'workspace:support', + }); + assert.deepEqual(lines, [ + 'RELAY_API_KEY=relay_ws_live_support', + 'Export this value before starting SDK-backed proactive runtime commands.', + ]); + } finally { + globalThis.fetch = originalFetch; + restoreEnv(); + } +}); + +test('tokens issue prints raw JSON when --json is set', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { lines, errors } = collector; + const restoreEnv = installEnvAuth(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + Response.json( + { + key: 'relay_ws_live_sales', + workspaceToken: { + workspaceId: 'sales', + kind: 'workspace_token', + name: 'workspace:sales', + }, + }, + { status: 200 } + )) as typeof globalThis.fetch; + + try { + await program.parseAsync(['node', 'agent-relay', 'tokens', 'issue', '--workspace', 'sales', '--json']); + + assert.deepEqual(errors, []); + assert.deepEqual(lines, [ + JSON.stringify( + { + key: 'relay_ws_live_sales', + workspaceToken: { + workspaceId: 'sales', + kind: 'workspace_token', + name: 'workspace:sales', + }, + }, + null, + 2 + ), + ]); + } finally { + globalThis.fetch = originalFetch; + restoreEnv(); + } +}); + +test('login prints a success message after fresh OAuth', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { lines, errors } = collector; + const originalConsoleLog = console.log; + const loginUrls: string[] = []; + let previousAuthFile: string | null = null; + + try { + previousAuthFile = await fs.readFile(AUTH_FILE_PATH, 'utf8'); + } catch { + previousAuthFile = null; + } + + console.log = ((...args: unknown[]) => { + const line = args.map((value) => String(value)).join(' '); + if (line.startsWith('Opening browser for cloud login: ')) { + loginUrls.push(line.slice('Opening browser for cloud login: '.length)); + } + }) as typeof console.log; + + try { + const loginPromise = program.parseAsync([ + 'node', + 'agent-relay', + 'login', + '--api-url', + 'https://cloud.test', + '--force', + ]); + + for (let attempt = 0; attempt < 300; attempt += 1) { + if (loginUrls.length > 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + assert.ok(loginUrls[0], 'expected browser login URL to be emitted'); + const loginUrl = new URL(loginUrls[0]); + const redirectUri = loginUrl.searchParams.get('redirect_uri'); + const state = loginUrl.searchParams.get('state'); + + assert.ok(redirectUri, 'expected redirect_uri in login URL'); + assert.ok(state, 'expected state in login URL'); + + const callbackUrl = new URL(redirectUri); + callbackUrl.searchParams.set('state', state); + callbackUrl.searchParams.set('access_token', 'access_token_test'); + callbackUrl.searchParams.set('refresh_token', 'refresh_token_test'); + callbackUrl.searchParams.set('access_token_expires_at', new Date(Date.now() + 60_000).toISOString()); + callbackUrl.searchParams.set('api_url', 'https://cloud.test'); + + const callbackResponse = await fetch(callbackUrl, { redirect: 'manual' }); + assert.equal(callbackResponse.status, 302); + + await loginPromise; + + assert.deepEqual(errors, []); + assert.deepEqual(lines, ['Logged in to https://cloud.test']); + } finally { + console.log = originalConsoleLog; + await restoreAuthFile(previousAuthFile); + delete process.env.CLOUD_API_URL; + delete process.env.CLOUD_API_ACCESS_TOKEN; + delete process.env.CLOUD_API_REFRESH_TOKEN; + delete process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT; + } +}, 10000); + +test('workspaces create prints a formatted result by default', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { lines, errors } = collector; + const restoreEnv = installEnvAuth(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + Response.json( + { + workspaceId: 'ws_support', + name: 'support', + relayfileUrl: 'https://relayfile.test', + relaycronUrl: 'https://relaycron.test', + relaycastUrl: 'https://relaycast.test', + relayauthUrl: 'https://relayauth.test', + joinCommand: 'relay on codex --workspace ws_support', + }, + { status: 201 } + )) as typeof globalThis.fetch; + + try { + await program.parseAsync(['node', 'agent-relay', 'workspaces', 'create', 'support']); + + assert.deepEqual(errors, []); + assert.ok(lines.includes('Workspace created: ws_support')); + assert.ok(lines.includes('Name: support')); + assert.ok(lines.includes('Relayfile URL: https://relayfile.test')); + assert.ok(lines.includes('Relaycron URL: https://relaycron.test')); + assert.ok(lines.includes('Relaycast URL: https://relaycast.test')); + assert.ok(lines.includes('Relayauth URL: https://relayauth.test')); + assert.ok(lines.includes('Join command: relay on codex --workspace ws_support')); + } finally { + globalThis.fetch = originalFetch; + restoreEnv(); + } +}); + +test('init reuses the workspace bootstrap flow', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { lines, errors } = collector; + const restoreEnv = installEnvAuth(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + Response.json( + { + workspaceId: 'ws_init', + name: 'init-workspace', + relayfileUrl: 'https://relayfile.test', + relaycronUrl: 'https://relaycron.test', + relaycastUrl: 'https://relaycast.test', + relayauthUrl: 'https://relayauth.test', + }, + { status: 201 } + )) as typeof globalThis.fetch; + + try { + await program.parseAsync(['node', 'agent-relay', 'init', 'init-workspace']); + + assert.deepEqual(errors, []); + assert.ok(lines.includes('Workspace created: ws_init')); + assert.ok(lines.includes('Name: init-workspace')); + assert.ok(lines.includes('Relayfile URL: https://relayfile.test')); + assert.ok(lines.includes('Relaycron URL: https://relaycron.test')); + assert.ok(lines.includes('Relaycast URL: https://relaycast.test')); + assert.ok(lines.includes('Relayauth URL: https://relayauth.test')); + } finally { + globalThis.fetch = originalFetch; + restoreEnv(); + } +}); + +test('tokens issue exits cleanly with a user-facing error on request failure', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { errors } = collector; + const restoreEnv = installEnvAuth(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + Response.json( + { error: 'workspace_not_found', message: 'Workspace not found' }, + { status: 404 } + )) as typeof globalThis.fetch; + + try { + await assert.rejects( + program.parseAsync(['node', 'agent-relay', 'tokens', 'issue', '--workspace', 'missing']), + /exit:1/ + ); + + assert.deepEqual(errors, [ + 'Workspace token issue failed at /api/v1/workspaces/missing/token: 404 workspace_not_found', + ]); + } finally { + globalThis.fetch = originalFetch; + restoreEnv(); + } +}); + +test('workspaces create exits cleanly with a user-facing error on request failure', async () => { + const collector = createLogCollector(); + const { program } = createHarness(collector.deps); + const { errors } = collector; + const restoreEnv = installEnvAuth(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + Response.json( + { error: 'invalid_request', message: 'Workspace name is invalid' }, + { status: 400 } + )) as typeof globalThis.fetch; + + try { + await assert.rejects( + program.parseAsync(['node', 'agent-relay', 'workspaces', 'create', '!!!']), + /exit:1/ + ); + + assert.deepEqual(errors, ['Workspace create failed at /api/v1/workspaces/create: 400 invalid_request']); + } finally { + globalThis.fetch = originalFetch; + restoreEnv(); + } +}); diff --git a/tsconfig.json b/tsconfig.json index a0208b921..aa875606c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,12 +3,8 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": [ - "ES2022" - ], - "types": [ - "node" - ], + "lib": ["ES2022"], + "types": ["node"], "outDir": "./dist", "rootDir": ".", "strict": true, @@ -21,57 +17,24 @@ "sourceMap": true, "baseUrl": ".", "paths": { - "@agent-relay/config": [ - "packages/config/dist/index.d.ts" - ], - "@agent-relay/config/*": [ - "packages/config/dist/*" - ], - "@agent-relay/trajectory": [ - "packages/trajectory/dist/index.d.ts" - ], - "@agent-relay/trajectory/*": [ - "packages/trajectory/dist/*" - ], - "@agent-relay/hooks": [ - "packages/hooks/dist/index.d.ts" - ], - "@agent-relay/hooks/*": [ - "packages/hooks/dist/*" - ], - "@agent-relay/policy": [ - "packages/policy/dist/index.d.ts" - ], - "@agent-relay/policy/*": [ - "packages/policy/dist/*" - ], - "@agent-relay/memory": [ - "packages/memory/dist/index.d.ts" - ], - "@agent-relay/memory/*": [ - "packages/memory/dist/*" - ], - "@agent-relay/utils": [ - "packages/utils/dist/index.d.ts" - ], - "@agent-relay/utils/*": [ - "packages/utils/dist/*" - ], - "@agent-relay/user-directory": [ - "packages/user-directory/dist/index.d.ts" - ], - "@agent-relay/user-directory/*": [ - "packages/user-directory/dist/*" - ] + "@agent-relay/config": ["packages/config/dist/index.d.ts"], + "@agent-relay/config/*": ["packages/config/dist/*"], + "@agent-relay/trajectory": ["packages/trajectory/dist/index.d.ts"], + "@agent-relay/trajectory/*": ["packages/trajectory/dist/*"], + "@agent-relay/hooks": ["packages/hooks/dist/index.d.ts"], + "@agent-relay/hooks/*": ["packages/hooks/dist/*"], + "@agent-relay/policy": ["packages/policy/dist/index.d.ts"], + "@agent-relay/policy/*": ["packages/policy/dist/*"], + "@agent-relay/memory": ["packages/memory/dist/index.d.ts"], + "@agent-relay/memory/*": ["packages/memory/dist/*"], + "@agent-relay/utils": ["packages/utils/dist/index.d.ts"], + "@agent-relay/utils/*": ["packages/utils/dist/*"], + "@agent-relay/cloud": ["packages/cloud/src/index.ts"], + "@agent-relay/cloud/*": ["packages/cloud/src/*"], + "@agent-relay/user-directory": ["packages/user-directory/dist/index.d.ts"], + "@agent-relay/user-directory/*": ["packages/user-directory/dist/*"] } }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "src/**/*.test.ts", - "packages/**/*" - ] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "packages/**/*"] } diff --git a/vitest.cli-tokens.config.ts b/vitest.cli-tokens.config.ts new file mode 100644 index 000000000..7e01b0efc --- /dev/null +++ b/vitest.cli-tokens.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/cli-tokens.test.ts'], + environment: 'node', + }, +});