diff --git a/README.md b/README.md index b4b2500..fde7875 100644 --- a/README.md +++ b/README.md @@ -1190,7 +1190,7 @@ Methods: - `system.capabilities` returns gateway protocol and node policy snapshot. Companion runtime helper: -- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, and `system.nodes`. +- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`). - `src/companion/platformClients.ts` provides platform-focused wrappers: - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 965a56f..355d7c0 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1430,5 +1430,5 @@ For more implementation details, see: - Protocol types: `src/gateway/protocol.ts` - Handlers: `src/gateway/handlers/` - Gateway server: `src/gateway/server.ts` -- Companion runtime client helper: `src/companion/runtimeClient.ts` +- Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers) - Platform companion wrappers: `src/companion/platformClients.ts` diff --git a/docs/plans/state.json b/docs/plans/state.json index 2cc21d2..3d32b27 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -185,6 +185,23 @@ ], "test_status": "pnpm test:run src/cli/onboard.test.ts src/cli/start.test.ts src/cli/index.test.ts + pnpm typecheck passing" }, + "companion-runtime-client-canvas-rpc-wrappers": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Extended companion runtime APIs with typed `canvas.*` RPC wrappers (`put/get/list/delete/clear`) and surfaced them through platform companion clients, with integration coverage against live gateway handlers and updated protocol/runtime documentation references.", + "files_modified": [ + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "src/companion/platformClients.ts", + "src/companion/platformClients.test.ts", + "src/companion/index.ts", + "README.md", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/index.ts b/src/companion/index.ts index 2a394ab..be535e1 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -15,6 +15,9 @@ export type { SetNodeStatusInput, SetNodeLocationInput, SetNodePushTokenInput, + PutCanvasArtifactInput, + GetCanvasArtifactInput, + DeleteCanvasArtifactInput, NodeRegisterResult, NodeCapabilitiesResult, NodeStatusSetResult, @@ -27,6 +30,12 @@ export type { NodeLocation, NodeStatus, NodePushSummary, + CanvasArtifact, + CanvasPutResult, + CanvasGetResult, + CanvasListResult, + CanvasDeleteResult, + CanvasClearResult, } from './runtimeClient.js'; export type { PlatformClientOptions, diff --git a/src/companion/platformClients.test.ts b/src/companion/platformClients.test.ts index b700c57..066dd49 100644 --- a/src/companion/platformClients.test.ts +++ b/src/companion/platformClients.test.ts @@ -18,6 +18,11 @@ function createRuntimeMock(): { setNodePushToken: ReturnType; getSystemCapabilities: ReturnType; listSystemNodes: ReturnType; + putCanvasArtifact: ReturnType; + getCanvasArtifact: ReturnType; + listCanvasArtifacts: ReturnType; + deleteCanvasArtifact: ReturnType; + clearCanvasArtifacts: ReturnType; } { const connect = vi.fn(async () => undefined); const disconnect = vi.fn(() => undefined); @@ -29,6 +34,11 @@ function createRuntimeMock(): { const setNodePushToken = vi.fn(async () => ({ updated: true })); const getSystemCapabilities = vi.fn(async () => ({ protocol: { version: 1 }, nodes: { enabled: true, locationEnabled: true, pushEnabled: true, allowedRoles: ['companion'], registered: true }, featureGates: {} })); const listSystemNodes = vi.fn(async () => ({ nodes: [], summary: { total: 0 } })); + const putCanvasArtifact = vi.fn(async () => ({ upserted: true, artifact: { id: 'a1' } })); + const getCanvasArtifact = vi.fn(async () => ({ artifact: { id: 'a1' } })); + const listCanvasArtifacts = vi.fn(async () => ({ artifacts: [{ id: 'a1' }] })); + const deleteCanvasArtifact = vi.fn(async () => ({ deleted: true })); + const clearCanvasArtifacts = vi.fn(async () => ({ cleared: 1 })); const runtime = { connect, @@ -41,6 +51,11 @@ function createRuntimeMock(): { setNodePushToken, getSystemCapabilities, listSystemNodes, + putCanvasArtifact, + getCanvasArtifact, + listCanvasArtifacts, + deleteCanvasArtifact, + clearCanvasArtifacts, } as unknown as CompanionRuntimeClient; return { @@ -55,6 +70,11 @@ function createRuntimeMock(): { setNodePushToken, getSystemCapabilities, listSystemNodes, + putCanvasArtifact, + getCanvasArtifact, + listCanvasArtifacts, + deleteCanvasArtifact, + clearCanvasArtifacts, }; } @@ -106,4 +126,29 @@ describe('platform companion clients', () => { expect(mock.setNodePushToken).toHaveBeenCalledWith({ provider: 'fcm', token: 'c'.repeat(64) }); expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'android', role: 'companion' }); }); + + it('macOS client forwards canvas methods to runtime client', async () => { + const mock = createRuntimeMock(); + const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); + + await client.putCanvasArtifact({ + sessionId: 'ws:test-canvas', + type: 'markdown', + content: { body: 'hello' }, + }); + await client.listCanvasArtifacts('ws:test-canvas'); + await client.getCanvasArtifact({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); + await client.deleteCanvasArtifact({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); + await client.clearCanvasArtifacts('ws:test-canvas'); + + expect(mock.putCanvasArtifact).toHaveBeenCalledWith({ + sessionId: 'ws:test-canvas', + type: 'markdown', + content: { body: 'hello' }, + }); + expect(mock.listCanvasArtifacts).toHaveBeenCalledWith('ws:test-canvas'); + expect(mock.getCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); + expect(mock.deleteCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); + expect(mock.clearCanvasArtifacts).toHaveBeenCalledWith('ws:test-canvas'); + }); }); diff --git a/src/companion/platformClients.ts b/src/companion/platformClients.ts index dd367bd..3ab8d0c 100644 --- a/src/companion/platformClients.ts +++ b/src/companion/platformClients.ts @@ -1,10 +1,18 @@ import type { + CanvasClearResult, + CanvasDeleteResult, + CanvasGetResult, + CanvasListResult, + CanvasPutResult, CompanionRuntimeClient, + DeleteCanvasArtifactInput, + GetCanvasArtifactInput, NodeCapabilitiesResult, NodeLocationGetResult, NodeLocationSetResult, NodeRegisterResult, NodeStatusSetResult, + PutCanvasArtifactInput, NodePushTokenSetResult, SetNodeLocationInput, SystemCapabilitiesResult, @@ -101,6 +109,26 @@ export class MacOSCompanionClient { listNodes(): Promise { return this.runtime.listSystemNodes({ platform: 'macos', role: this.role }); } + + putCanvasArtifact(input: PutCanvasArtifactInput): Promise { + return this.runtime.putCanvasArtifact(input); + } + + getCanvasArtifact(input: GetCanvasArtifactInput): Promise { + return this.runtime.getCanvasArtifact(input); + } + + listCanvasArtifacts(sessionId: string): Promise { + return this.runtime.listCanvasArtifacts(sessionId); + } + + deleteCanvasArtifact(input: DeleteCanvasArtifactInput): Promise { + return this.runtime.deleteCanvasArtifact(input); + } + + clearCanvasArtifacts(sessionId: string): Promise { + return this.runtime.clearCanvasArtifacts(sessionId); + } } export class IOSCompanionClient { @@ -174,6 +202,26 @@ export class IOSCompanionClient { listNodes(): Promise { return this.runtime.listSystemNodes({ platform: 'ios', role: this.role }); } + + putCanvasArtifact(input: PutCanvasArtifactInput): Promise { + return this.runtime.putCanvasArtifact(input); + } + + getCanvasArtifact(input: GetCanvasArtifactInput): Promise { + return this.runtime.getCanvasArtifact(input); + } + + listCanvasArtifacts(sessionId: string): Promise { + return this.runtime.listCanvasArtifacts(sessionId); + } + + deleteCanvasArtifact(input: DeleteCanvasArtifactInput): Promise { + return this.runtime.deleteCanvasArtifact(input); + } + + clearCanvasArtifacts(sessionId: string): Promise { + return this.runtime.clearCanvasArtifacts(sessionId); + } } export class AndroidCompanionClient { @@ -245,4 +293,24 @@ export class AndroidCompanionClient { listNodes(): Promise { return this.runtime.listSystemNodes({ platform: 'android', role: this.role }); } + + putCanvasArtifact(input: PutCanvasArtifactInput): Promise { + return this.runtime.putCanvasArtifact(input); + } + + getCanvasArtifact(input: GetCanvasArtifactInput): Promise { + return this.runtime.getCanvasArtifact(input); + } + + listCanvasArtifacts(sessionId: string): Promise { + return this.runtime.listCanvasArtifacts(sessionId); + } + + deleteCanvasArtifact(input: DeleteCanvasArtifactInput): Promise { + return this.runtime.deleteCanvasArtifact(input); + } + + clearCanvasArtifacts(sessionId: string): Promise { + return this.runtime.clearCanvasArtifacts(sessionId); + } } diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts index 91dcdaa..9fad243 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -224,4 +224,48 @@ describe('CompanionRuntimeClient', () => { client.disconnect(); } }); + + it('supports canvas artifact lifecycle via typed helper methods', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const client = new CompanionRuntimeClient({ + url: `ws://127.0.0.1:${TEST_PORT}`, + token: TEST_TOKEN, + }); + + await client.connect(); + + try { + const sessionId = 'ws:companion-canvas'; + const put = await client.putCanvasArtifact({ + sessionId, + artifactId: 'artifact-1', + type: 'markdown', + title: 'Companion note', + content: { body: '# Hello' }, + metadata: { source: 'runtime-client-test' }, + }); + expect(put.upserted).toBe(true); + expect(put.artifact.id).toBe('artifact-1'); + expect(put.artifact.type).toBe('markdown'); + + const list = await client.listCanvasArtifacts(sessionId); + expect(list.artifacts.length).toBeGreaterThanOrEqual(1); + expect(list.artifacts.some((artifact) => artifact.id === 'artifact-1')).toBe(true); + + const get = await client.getCanvasArtifact({ sessionId, artifactId: 'artifact-1' }); + expect(get.artifact.id).toBe('artifact-1'); + expect(get.artifact.title).toBe('Companion note'); + + const del = await client.deleteCanvasArtifact({ sessionId, artifactId: 'artifact-1' }); + expect(del.deleted).toBe(true); + + const clear = await client.clearCanvasArtifacts(sessionId); + expect(clear.cleared).toBe(0); + } finally { + client.disconnect(); + } + }); }); diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index 805f03e..6e0aca8 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -185,6 +185,56 @@ export interface SetNodeLocationInput extends Omit {} +export interface CanvasArtifact { + id: string; + type: string; + title?: string; + content: unknown; + metadata?: Record; + createdAt: number; + updatedAt: number; +} + +export interface PutCanvasArtifactInput { + sessionId: string; + artifactId?: string; + type: string; + title?: string; + content: unknown; + metadata?: Record; +} + +export interface GetCanvasArtifactInput { + sessionId: string; + artifactId: string; +} + +export interface DeleteCanvasArtifactInput { + sessionId: string; + artifactId: string; +} + +export interface CanvasPutResult { + artifact: CanvasArtifact; + upserted: boolean; +} + +export interface CanvasGetResult { + artifact: CanvasArtifact; +} + +export interface CanvasListResult { + artifacts: CanvasArtifact[]; +} + +export interface CanvasDeleteResult { + deleted: boolean; +} + +export interface CanvasClearResult { + cleared: number; +} + export class GatewayRpcError extends Error { readonly code: number; @@ -378,6 +428,39 @@ export class CompanionRuntimeClient { }); } + putCanvasArtifact(input: PutCanvasArtifactInput): Promise { + return this.call('canvas.put', { + sessionId: input.sessionId, + artifactId: input.artifactId, + type: input.type, + title: input.title, + content: input.content, + metadata: input.metadata, + }); + } + + getCanvasArtifact(input: GetCanvasArtifactInput): Promise { + return this.call('canvas.get', { + sessionId: input.sessionId, + artifactId: input.artifactId, + }); + } + + listCanvasArtifacts(sessionId: string): Promise { + return this.call('canvas.list', { sessionId }); + } + + deleteCanvasArtifact(input: DeleteCanvasArtifactInput): Promise { + return this.call('canvas.delete', { + sessionId: input.sessionId, + artifactId: input.artifactId, + }); + } + + clearCanvasArtifacts(sessionId: string): Promise { + return this.call('canvas.clear', { sessionId }); + } + private handleMessage(raw: string): void { let parsed: RpcMessage; try {