@roll-agent/companion 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -62
- package/dist/companion-workspace.d.ts +22 -5
- package/dist/companion-workspace.js +1 -1
- package/dist/event-buffer.d.ts +1 -1
- package/dist/event-buffer.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -1
- package/dist/interaction-broker.d.ts +86 -0
- package/dist/interaction-broker.js +1 -0
- package/dist/relay-bridge-v11.d.ts +112 -0
- package/dist/relay-bridge-v11.js +1 -0
- package/dist/relay-bridge.js +1 -1
- package/dist/relay-frame-buffer.d.ts +55 -0
- package/dist/relay-frame-buffer.js +1 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +1 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,104 +1,137 @@
|
|
|
1
1
|
# `@roll-agent/companion`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
连接远程 Web UI 与用户本机 Roll Runtime 的 Local Companion 基础库。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
本包只运行在用户本机的 Node.js Host(例如 Electron Main 或独立 Companion daemon),消费
|
|
6
|
+
`@roll-agent/relay-protocol` 的 Relay Wire 契约,并提供 Workspace 生命周期、Interaction
|
|
7
|
+
Broker、Relay ACK/gap 缓冲、mutation 去重、出站重连和可插拔 payload cipher。
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
`@roll-agent/relay-protocol` 唯一维护;本包不再定义另一套协议。
|
|
9
|
+
它不包含生产 Cloud Relay、Browser SDK、账号/设备身份、controller 选举、TLS、可靠投递、
|
|
10
|
+
持久 outbox、Interaction WAL、本地确认 UI 或自动启动机制。安装本包或
|
|
11
|
+
`@roll-agent/core` 不会建立远程连接;宿主必须显式完成认证并启动 bridge。
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
UI、HA 或监控,也不提供 CLI、daemon 或自动启动机制。`npm install` 与
|
|
16
|
-
`npm install -g @roll-agent/core` 都不会启动 Companion;宿主必须在用户显式启用远程访问
|
|
17
|
-
后完成配对,并显式启动 `OutboundCompanionRelay` 或向 `CompanionRelayBridge` 绑定
|
|
18
|
-
Transport。
|
|
19
|
-
|
|
20
|
-
## 在 Local Companion Host 中安装
|
|
13
|
+
## 安装
|
|
21
14
|
|
|
22
15
|
```bash
|
|
23
16
|
pnpm add @roll-agent/companion @roll-agent/client-node @roll-agent/relay-protocol
|
|
24
17
|
```
|
|
25
18
|
|
|
26
|
-
##
|
|
19
|
+
## Relay Wire 1.1
|
|
27
20
|
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
Wire 1.1 使用显式的 `CompanionRelayBridgeV11` API。Runtime 的
|
|
22
|
+
`approval.request` / `userInput.request` 由 `CompanionInteractionBroker` 注册为 named
|
|
23
|
+
handlers,再投影成安全的 `interaction.request/resolved/cancelled` 帧。远端只能通过
|
|
24
|
+
`interaction.candidate` 提交候选结果。
|
|
30
25
|
|
|
31
26
|
```ts
|
|
32
27
|
import { RollNodeClient } from "@roll-agent/client-node";
|
|
33
28
|
import {
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
CompanionInteractionBroker,
|
|
30
|
+
CompanionRelayBridgeV11,
|
|
36
31
|
CompanionWorkspace,
|
|
37
|
-
|
|
32
|
+
OutboundCompanionRelayV11,
|
|
33
|
+
createRuntimeServerRequestHandlers,
|
|
38
34
|
} from "@roll-agent/companion";
|
|
39
35
|
import { deviceIdSchema, workspaceIdSchema } from "@roll-agent/relay-protocol";
|
|
40
36
|
|
|
41
|
-
const
|
|
37
|
+
const interactionBroker = new CompanionInteractionBroker();
|
|
42
38
|
const runtime = await RollNodeClient.start({
|
|
43
39
|
cwd: workspacePath,
|
|
44
|
-
serverRequestHandlers:
|
|
45
|
-
"approval.request": approvalRequestBroker.handle,
|
|
46
|
-
},
|
|
40
|
+
serverRequestHandlers: createRuntimeServerRequestHandlers(interactionBroker),
|
|
47
41
|
});
|
|
42
|
+
|
|
48
43
|
const workspaceId = workspaceIdSchema.parse(savedWorkspaceId);
|
|
49
44
|
const workspace = new CompanionWorkspace({
|
|
50
45
|
client: runtime,
|
|
51
|
-
|
|
46
|
+
workspaceId,
|
|
47
|
+
interactionBroker,
|
|
52
48
|
localApprovalPolicy: async () => "require-local-confirmation",
|
|
53
49
|
});
|
|
54
|
-
const bridge = new
|
|
50
|
+
const bridge = new CompanionRelayBridgeV11({
|
|
55
51
|
deviceId: deviceIdSchema.parse(savedDeviceId),
|
|
56
52
|
pairingToken,
|
|
57
53
|
workspaces: new Map([[workspaceId, workspace]]),
|
|
58
54
|
});
|
|
59
|
-
const outbound = new
|
|
55
|
+
const outbound = new OutboundCompanionRelayV11({
|
|
60
56
|
bridge,
|
|
61
|
-
connectTransport:
|
|
57
|
+
connectTransport: async () => ({
|
|
58
|
+
transport: await openAuthenticatedRelayTransport(),
|
|
59
|
+
responderContext: authenticatedRelaySession,
|
|
60
|
+
responderPolicy: async ({ responderContext, signal }) =>
|
|
61
|
+
authorizeInteractionResponder(responderContext, { signal }),
|
|
62
|
+
}),
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
outbound.start();
|
|
65
66
|
```
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`
|
|
78
|
-
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
68
|
+
`responderContext` 是宿主注入的不透明认证/会话状态;Companion 不解释它,也不因其存在就声称
|
|
69
|
+
Browser 已认证或 controller 已选出。Wire Schema 先拒绝畸形帧;合法 candidate 再经过宿主的
|
|
70
|
+
responder policy,然后按 Runtime 原始请求做 method-specific 校验。Approval 的 approve 候选
|
|
71
|
+
还必须通过本地 Approval Policy;reject 不扩大权限。User Input 候选按原始表单规范化,完整
|
|
72
|
+
结果只回给 Runtime。
|
|
73
|
+
|
|
74
|
+
Wire 投影有意最小化:
|
|
75
|
+
|
|
76
|
+
- Approval 只包含 `approvalId/agentName/toolName/explanation?`。
|
|
77
|
+
- User Input 只包含安全表单字段。
|
|
78
|
+
- Runtime JSON-RPC `id`、原始 Tool input/output、secret、完整 User Input Result 和本地授权
|
|
79
|
+
状态都不会进入 Wire。
|
|
80
|
+
- `authentication.request` 与 File Picker 不注册远程 projector,保持 local-only。
|
|
81
|
+
|
|
82
|
+
查询通道(pull)与事件通道(push)执行同一投影纪律:Wire 1.1 的 `thread.snapshot` /
|
|
83
|
+
`thread.open` 结果中 `pendingApprovals[].preview` 被替换为 `{ explanation }` 或 `null`、
|
|
84
|
+
`reason` 被移除;`operations[].display` 替换为 `null`、`outcome.reason` 被移除
|
|
85
|
+
(`operation.get` 同规则)。
|
|
86
|
+
|
|
87
|
+
已知保留的远端暴露面(有意决策,记录在案):
|
|
88
|
+
|
|
89
|
+
- `thread.capabilities` 结果中的 `manifest` 原样传输,可能包含 cwd、平台、模型、Agent、
|
|
90
|
+
Skill、Tool Schema、规则标识与 VCS 等 capability 元数据;远端 UI 依赖它做功能门控。
|
|
91
|
+
- `thread.list` / snapshot 中的 Thread 标题、模型与时间元数据,以及 transcript 的用户/助手
|
|
92
|
+
完整消息文本。
|
|
93
|
+
- Timeline 中已列入安全 Schema 的消息与 reasoning 摘要、Turn 状态、Tool 名称和安全完成
|
|
94
|
+
摘要。它们仍是用户内容或工作流元数据,Relay/Browser 宿主必须按敏感数据保护。
|
|
95
|
+
|
|
96
|
+
Wire 1.0 没有 query/interaction 安全投影;只有错误响应正文会替换为固定公开文案,避免
|
|
97
|
+
Runtime 或本地 policy 细节进入 Relay。其余 legacy payload 仍按冻结行为原样传输,因此它只
|
|
98
|
+
适用于宿主已建立等价本地信任边界的 legacy peer,不能作为面向不受信 Cloud/Browser 的安全
|
|
99
|
+
降级路径;远程部署必须协商 Wire 1.1,协商失败时应拒绝连接。
|
|
100
|
+
|
|
101
|
+
Runtime cancel、Turn 终态、deadline、Workspace 解绑、bridge 关闭和重连 generation 失效共用
|
|
102
|
+
单次终止语义;旧 generation 的迟到候选不会结算新的连接。`interaction.resolved` 也不携带完整
|
|
103
|
+
结果,Browser 只用终态帧收敛视图。
|
|
104
|
+
|
|
105
|
+
## Wire 1.0 兼容
|
|
106
|
+
|
|
107
|
+
旧的 `CompanionRelayBridge.connect(transport)`、`OutboundCompanionRelay`、通用未带版本后缀的
|
|
108
|
+
Relay exports,以及 `CompanionApprovalRequestBroker` 均固定为 Wire 1.0。后者已 deprecated,
|
|
109
|
+
保留一个 minor 周期用于 `approval.required` / `approval.candidate` 兼容路径。
|
|
110
|
+
|
|
111
|
+
新 Host 必须显式选择 `CompanionRelayBridgeV11` 才能发送 typed Interaction;协商到 1.0 的
|
|
112
|
+
peer 只使用既有 Approval 路径,不会收到 1.1 Interaction 帧。
|
|
113
|
+
|
|
114
|
+
Wire 1.0 的 registry、Schema 和 fixtures 冻结不代表其内容经过安全投影。生产 Host 不得因
|
|
115
|
+
版本回落而把原本要求 1.1 的远程会话接到 1.0 bridge。
|
|
116
|
+
|
|
117
|
+
## ACK、重连与加密
|
|
118
|
+
|
|
119
|
+
Wire 1.1 的安全 Runtime event 与 Interaction 帧共享单个 Workspace `relaySequence`。ACK 只能
|
|
120
|
+
推进当前 generation 已成功发送的连续前缀;重连会重投未 ACK 帧,缓冲缺口要求
|
|
121
|
+
`thread.snapshot` 收敛。所有 buffer、ACK、dedupe 和 lease 都只在 Companion 进程内,不能当作
|
|
122
|
+
持久事件日志或可靠 outbox。
|
|
123
|
+
|
|
124
|
+
配置 `RelayPayloadCipherV11` 后,request/response/event/interaction 必须走
|
|
125
|
+
`runtime.encrypted`。算法、AEAD、nonce、密钥协商/轮换和 Browser 实现仍由宿主负责。
|
|
126
|
+
|
|
127
|
+
## 测试 fake
|
|
128
|
+
|
|
129
|
+
`@roll-agent/companion/testing` 导出 `InMemoryRelayTransportV11`,用于协议测试中的候选注入、
|
|
130
|
+
重复、ACK、断线和旧 generation 迟到消息。它不提供生产身份、鉴权、controller 选举、可靠
|
|
131
|
+
投递、持久 outbox 或 WAL,不能作为生产 Transport。
|
|
99
132
|
|
|
100
133
|
## 文档
|
|
101
134
|
|
|
102
|
-
- [`@roll-agent/relay-protocol` Relay
|
|
135
|
+
- [`@roll-agent/relay-protocol` Relay 1.1/1.0 参考](https://github.com/steveoon/roll-agent/blob/main/docs/companion-relay-v1-reference.md)
|
|
103
136
|
- [使用自己的技术栈接入 Roll](https://github.com/steveoon/roll-agent/blob/main/docs/how-to-build-roll-runtime-ui.md)
|
|
104
137
|
- [Runtime Protocol 架构与安全边界](https://github.com/steveoon/roll-agent/blob/main/docs/runtime-protocol-architecture.md)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { RUNTIME_SERVER_REQUEST_METHODS, type InitializeResult, type PendingApproval, type RuntimeEventEnvelope, type RuntimeMethod, type RuntimeMethodInput, type RuntimeMethodResult } from "@roll-agent/protocol";
|
|
1
|
+
import { RUNTIME_SERVER_REQUEST_METHODS, type InitializeResult, type PendingApproval, type RuntimeEventEnvelope, type RuntimeMethod, type RuntimeMethodInput, type RuntimeMethodResult, type RuntimeMethodResultForVersion, type RuntimeProtocolVersion } from "@roll-agent/protocol";
|
|
2
2
|
import type { RollNodeClient, RuntimeServerRequestHandler } from "@roll-agent/client-node";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { CompanionEventBuffer, type CompanionEventBufferOptions, type EventBufferReplay } from "./event-buffer.ts";
|
|
5
|
+
import { CompanionRelayFrameBuffer, type CompanionRelayFrameEntryV11, type CompanionRelayFrameReplayV11 } from "./relay-frame-buffer.ts";
|
|
6
|
+
import { CompanionInteractionBroker, type RemoteInteractionCandidateContext } from "./interaction-broker.ts";
|
|
5
7
|
import { WorkspaceLeaseManager } from "./lease-manager.ts";
|
|
6
|
-
import { type RelayApprovalCandidateInput, type RelayApprovalCandidateParams, type RelayApprovalCandidateResult, type
|
|
8
|
+
import { type RelayApprovalCandidateInput, type RelayApprovalCandidateParams, type RelayApprovalCandidateResult, type RelayInteractionCandidateParamsV11, type RelayInteractionCandidateResultV11, type RelayRuntimeRequest, type RelayRuntimeRequestV11, type WorkspaceId } from "@roll-agent/relay-protocol";
|
|
7
9
|
export declare const LOCAL_APPROVAL_DECISIONS: readonly ["allow", "deny", "require-local-confirmation"];
|
|
8
10
|
export declare const localApprovalDecisionSchema: z.ZodEnum<["allow", "deny", "require-local-confirmation"]>;
|
|
9
11
|
export type LocalApprovalDecision = z.infer<typeof localApprovalDecisionSchema>;
|
|
@@ -12,7 +14,7 @@ export interface LocalApprovalPolicyContext {
|
|
|
12
14
|
}
|
|
13
15
|
export type LocalApprovalPolicy = (approval: PendingApproval, context: LocalApprovalPolicyContext) => LocalApprovalDecision | Promise<LocalApprovalDecision>;
|
|
14
16
|
export interface CompanionRuntimeClient {
|
|
15
|
-
request<TMethod extends RuntimeMethod>(method: TMethod, input: RuntimeMethodInput<TMethod>): Promise<
|
|
17
|
+
request<TMethod extends RuntimeMethod>(method: TMethod, input: RuntimeMethodInput<TMethod>): Promise<RuntimeMethodResultForVersion<RuntimeProtocolVersion, TMethod>>;
|
|
16
18
|
onEvent(listener: (event: RuntimeEventEnvelope) => void): () => void;
|
|
17
19
|
getInitializationResult?(): Pick<InitializeResult, "protocolVersion">;
|
|
18
20
|
close(): void;
|
|
@@ -21,6 +23,9 @@ export interface CompanionRuntimeClient {
|
|
|
21
23
|
export interface CompanionWorkspaceOptions extends CompanionEventBufferOptions {
|
|
22
24
|
readonly client: CompanionRuntimeClient;
|
|
23
25
|
readonly localApprovalPolicy: LocalApprovalPolicy;
|
|
26
|
+
readonly workspaceId?: WorkspaceId;
|
|
27
|
+
readonly interactionBroker?: CompanionInteractionBroker;
|
|
28
|
+
/** @deprecated Use interactionBroker with createRuntimeServerRequestHandlers(). */
|
|
24
29
|
readonly approvalRequestBroker?: CompanionApprovalRequestBroker;
|
|
25
30
|
}
|
|
26
31
|
export declare class LocalApprovalDeniedError extends Error {
|
|
@@ -30,9 +35,10 @@ export declare class LocalConfirmationRequiredError extends Error {
|
|
|
30
35
|
constructor(message: string);
|
|
31
36
|
}
|
|
32
37
|
export declare class InvalidRelayRequestParamsError extends Error {
|
|
33
|
-
readonly method:
|
|
34
|
-
constructor(method:
|
|
38
|
+
readonly method: string;
|
|
39
|
+
constructor(method: string);
|
|
35
40
|
}
|
|
41
|
+
/** @deprecated Use CompanionInteractionBroker with createRuntimeServerRequestHandlers(). */
|
|
36
42
|
export declare class CompanionApprovalRequestBroker {
|
|
37
43
|
readonly leases: WorkspaceLeaseManager;
|
|
38
44
|
readonly handle: RuntimeServerRequestHandler<typeof RUNTIME_SERVER_REQUEST_METHODS.approvalRequest>;
|
|
@@ -48,14 +54,18 @@ export declare class CompanionApprovalRequestBroker {
|
|
|
48
54
|
export declare class CompanionWorkspace {
|
|
49
55
|
readonly leases: WorkspaceLeaseManager;
|
|
50
56
|
readonly events: CompanionEventBuffer;
|
|
57
|
+
readonly relayFramesV11: CompanionRelayFrameBuffer;
|
|
51
58
|
private readonly client;
|
|
52
59
|
private readonly localApprovalPolicy;
|
|
53
60
|
private readonly protocolCapabilities;
|
|
61
|
+
private readonly interactionBroker;
|
|
54
62
|
private readonly approvalRequestBroker;
|
|
55
63
|
private readonly approvals;
|
|
56
64
|
private readonly eventListeners;
|
|
65
|
+
private readonly relayFrameListenersV11;
|
|
57
66
|
private readonly releaseClientSubscription;
|
|
58
67
|
private readonly releaseApprovalPolicyBinding;
|
|
68
|
+
private readonly releaseInteractionBinding;
|
|
59
69
|
private closed;
|
|
60
70
|
constructor(options: CompanionWorkspaceOptions);
|
|
61
71
|
attachBrowser(clientId: string): () => void;
|
|
@@ -65,14 +75,21 @@ export declare class CompanionWorkspace {
|
|
|
65
75
|
readonly relaySequence: number;
|
|
66
76
|
readonly event: RuntimeEventEnvelope;
|
|
67
77
|
}) => void): () => void;
|
|
78
|
+
onBufferedRelayFrameV11(listener: (entry: CompanionRelayFrameEntryV11) => void): () => void;
|
|
68
79
|
replay(afterRelaySequence?: number): EventBufferReplay;
|
|
69
80
|
acknowledge(throughRelaySequence: number): void;
|
|
81
|
+
replayRelayFramesV11(afterRelaySequence?: number): CompanionRelayFrameReplayV11;
|
|
82
|
+
acknowledgeRelayFramesV11(throughRelaySequence: number): void;
|
|
70
83
|
startTurn(input: RuntimeMethodInput<"turn.start">): Promise<RuntimeMethodResult<"turn.start">>;
|
|
71
84
|
respondApproval(input: RuntimeMethodInput<"approval.respond">): Promise<RuntimeMethodResult<"approval.respond">>;
|
|
72
85
|
submitApprovalCandidate(input: RelayApprovalCandidateInput): Promise<RelayApprovalCandidateResult>;
|
|
86
|
+
submitInteractionCandidateV11(input: RelayInteractionCandidateParamsV11, context: RemoteInteractionCandidateContext): Promise<RelayInteractionCandidateResultV11>;
|
|
73
87
|
handleRemoteRequest(request: RelayRuntimeRequest): Promise<unknown>;
|
|
88
|
+
handleRemoteRequestV11(request: RelayRuntimeRequestV11, context: RemoteInteractionCandidateContext): Promise<unknown>;
|
|
89
|
+
closeRemoteInteractions(reason: Error): void;
|
|
74
90
|
closeIfIdle(): Promise<boolean>;
|
|
75
91
|
private handleEvent;
|
|
92
|
+
private publishRelayFrameV11;
|
|
76
93
|
private updateLeases;
|
|
77
94
|
}
|
|
78
95
|
export declare function isRollNodeClient(client: CompanionRuntimeClient): client is RollNodeClient;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{RUNTIME_METHODS as e,RUNTIME_SERVER_REQUEST_METHODS as r,getRuntimeProtocolCapabilities as a,parseRuntimeMethodParams as t}from"@roll-agent/protocol";import{z as o}from"zod";import{CompanionEventBuffer as n}from"./event-buffer.js";import{WorkspaceLeaseManager as i}from"./lease-manager.js";import{RELAY_REQUEST_METHODS as s,relayApprovalCandidateParamsSchema as l}from"@roll-agent/relay-protocol";export const LOCAL_APPROVAL_DECISIONS=["allow","deny","require-local-confirmation"];export const localApprovalDecisionSchema=o.enum(LOCAL_APPROVAL_DECISIONS);export class LocalApprovalDeniedError extends Error{constructor(e){super(e),this.name="LocalApprovalDeniedError"}}export class LocalConfirmationRequiredError extends Error{constructor(e){super(e),this.name="LocalConfirmationRequiredError"}}export class InvalidRelayRequestParamsError extends Error{method;constructor(e){super(`Invalid params for Relay method "${e}"`),this.name="InvalidRelayRequestParamsError",this.method=e}}function p(e,r){try{return t(e,r)}catch{throw new InvalidRelayRequestParamsError(e)}}function c(e){const r=l.safeParse(e);if(!r.success)throw new InvalidRelayRequestParamsError(s.approvalCandidate);return r.data}function d(e){const r=localApprovalDecisionSchema.safeParse(e);if(!r.success)throw new LocalApprovalDeniedError("Local Companion policy returned an invalid approval decision");return r.data}function v(e){return e instanceof Error?e:new Error(void 0===e?"Runtime cancelled the approval request":String(e))}async function h(e,r,a){if(r.aborted)throw v(r.reason);let t=()=>{};const o=new Promise((e,r)=>{t=r}),n=()=>{t(v(r.reason))};r.addEventListener("abort",n,{once:!0});try{const t=Promise.resolve().then(()=>{if(r.aborted)throw v(r.reason);return a(e,{signal:r})});let n;try{n=await Promise.race([t,o])}catch{if(r.aborted)throw v(r.reason);throw new Error("Local Companion policy failed")}return d(n)}finally{r.removeEventListener("abort",n)}}export class CompanionApprovalRequestBroker{leases=new i;handle=(e,r)=>this.handleApprovalRequest(e,r);pendingRequests=new Map;localApprovalPolicy;bindLocalApprovalPolicy(e){if(void 0!==this.localApprovalPolicy)throw new Error("Companion approval request broker is already bound to a workspace");this.localApprovalPolicy=e;let r=!1;return()=>{r||(r=!0,this.localApprovalPolicy===e&&(this.localApprovalPolicy=void 0))}}async submitCandidate(e){const r=this.pendingRequests.get(e.approvalId);if(void 0===r)throw new LocalApprovalDeniedError(`Approval "${e.approvalId}" is no longer pending`);if(r.params.threadId!==e.threadId||r.params.approval.turnId!==e.turnId)throw new LocalApprovalDeniedError(`Approval "${e.approvalId}" does not belong to the requested thread and turn`);if("reject"===e.decision)return r.activeApproveController?.abort(new LocalApprovalDeniedError(`Approval "${e.approvalId}" approval candidate was superseded by rejection`)),this.assertStillPending(r),this.resolvePending(r,{decision:"reject",...void 0!==e.reason?{reason:e.reason}:{}}),{accepted:!0};if(void 0!==r.activeApproveController)throw new LocalApprovalDeniedError(`Approval "${e.approvalId}" already has an approval candidate in progress`);const a=new AbortController;r.activeApproveController=a;try{const e=this.localApprovalPolicy;if(void 0===e)throw new LocalApprovalDeniedError("Companion approval request broker is not bound to a local policy");const t=await h(r.params.approval,AbortSignal.any([r.signal,a.signal]),e);if(this.assertStillPending(r),"deny"===t)throw this.resolvePending(r,{decision:"reject",reason:"Local Companion policy denied remote approval"}),new LocalApprovalDeniedError("Local Companion policy denied remote approval");if("require-local-confirmation"===t)throw new LocalConfirmationRequiredError("Local confirmation is required before this approval can continue");return this.assertStillPending(r),this.resolvePending(r,{decision:"approve"}),{accepted:!0}}catch(a){throw a instanceof LocalConfirmationRequiredError||this.pendingRequests.get(e.approvalId)!==r||this.rejectPending(r,a instanceof Error?a:new LocalApprovalDeniedError("Local Companion policy failed")),a}finally{r.activeApproveController===a&&(r.activeApproveController=void 0)}}handleApprovalRequest(e,r){if(r.signal.aborted)return Promise.reject(v(r.signal.reason));const a=this.pendingRequests.get(e.approval.id);if(void 0!==a)return Promise.reject(new Error(`Approval "${e.approval.id}" is already pending for Runtime request `+JSON.stringify(a.requestId)));let t=()=>{},o=()=>{};const n=new Promise((e,r)=>{t=e,o=r}),i={params:e,requestId:r.requestId,signal:r.signal,resolve:t,reject:o,releaseLease:this.leases.acquire({kind:"approval",id:e.approval.id}),activeApproveController:void 0},s=()=>{this.rejectPending(i,v(r.signal.reason))};return this.pendingRequests.set(e.approval.id,i),r.signal.addEventListener("abort",s,{once:!0}),r.signal.aborted&&s(),n.finally(()=>{r.signal.removeEventListener("abort",s)})}assertStillPending(e){if(this.pendingRequests.get(e.params.approval.id)!==e)throw new LocalApprovalDeniedError(`Approval "${e.params.approval.id}" is no longer pending`)}resolvePending(e,r){this.pendingRequests.delete(e.params.approval.id),e.releaseLease(),e.resolve(r)}rejectPending(e,r){this.pendingRequests.get(e.params.approval.id)===e&&(this.pendingRequests.delete(e.params.approval.id),e.releaseLease(),e.reject(r))}}export class CompanionWorkspace{leases;events;client;localApprovalPolicy;protocolCapabilities;approvalRequestBroker;approvals=new Map;eventListeners=new Set;releaseClientSubscription;releaseApprovalPolicyBinding;closed=!1;constructor(e){if(this.client=e.client,this.localApprovalPolicy=e.localApprovalPolicy,this.protocolCapabilities=a(this.client.getInitializationResult?.().protocolVersion??"1.0"),this.approvalRequestBroker=e.approvalRequestBroker,this.protocolCapabilities.serverRequests&&void 0===this.approvalRequestBroker)throw new Error("The negotiated Runtime Protocol requires a CompanionApprovalRequestBroker registered during client initialization");this.events=new n({...void 0!==e.maxEvents?{maxEvents:e.maxEvents}:{},...void 0!==e.maxBytes?{maxBytes:e.maxBytes}:{}}),this.leases=this.approvalRequestBroker?.leases??new i;const r=this.approvalRequestBroker?.bindLocalApprovalPolicy(this.localApprovalPolicy);try{this.releaseClientSubscription=this.client.onEvent(e=>this.handleEvent(e))}catch(e){throw r?.(),e}this.releaseApprovalPolicyBinding=r}attachBrowser(e){return this.leases.acquire({kind:"client",id:e})}detachBrowser(e){return this.leases.releaseClient(e)}acquireBackgroundShellLease(e){return this.leases.acquire({kind:"shell-session",id:e})}onBufferedEvent(e){return this.eventListeners.add(e),()=>{this.eventListeners.delete(e)}}replay(e=-1){return this.events.replay(e)}acknowledge(e){this.events.acknowledge(e)}async startTurn(r){const a=t(e.turnStart,r),o=this.leases.acquire({kind:"turn",id:a.turnId});try{return await this.client.request(e.turnStart,a)}catch(e){throw o(),e}}async respondApproval(r){const a=t(e.approvalRespond,r);if(!this.protocolCapabilities.clientApprovalResponses)throw new LocalApprovalDeniedError("The negotiated Runtime Protocol must use the Relay approval.candidate method");const o=this.approvals.get(a.approvalId);if(void 0===o)throw new LocalApprovalDeniedError(`Approval "${a.approvalId}" is no longer pending`);if("approve"===a.decision){const e=await h(o.approval,o.controller.signal,this.localApprovalPolicy);if(o.controller.signal.aborted||this.approvals.get(a.approvalId)!==o)throw v(o.controller.signal.reason);if("deny"===e)throw new LocalApprovalDeniedError("Local Companion policy denied remote approval");if("require-local-confirmation"===e)throw new LocalConfirmationRequiredError("Local confirmation is required before this approval can continue")}const n=await this.client.request(e.approvalRespond,a);return this.approvals.get(a.approvalId)===o&&(o.controller.abort(new LocalApprovalDeniedError(`Approval "${a.approvalId}" was resolved by another response`)),this.approvals.delete(a.approvalId),this.leases.release({kind:"approval",id:a.approvalId})),n}async submitApprovalCandidate(e){const r=c(e);if(!this.protocolCapabilities.serverRequests)throw new LocalApprovalDeniedError("Relay approval.candidate requires Runtime server-request capability");const a=this.approvalRequestBroker;if(void 0===a)throw new LocalApprovalDeniedError("Runtime server-request approval broker is unavailable");return a.submitCandidate(r)}async handleRemoteRequest(r){switch(r.method){case s.approvalCandidate:return this.submitApprovalCandidate(c(r.params));case e.initialize:throw new LocalApprovalDeniedError("initialize is owned by the local Companion and cannot be relayed");case e.threadList:return this.client.request(e.threadList,p(e.threadList,r.params));case e.threadCreate:return this.client.request(e.threadCreate,p(e.threadCreate,r.params));case e.threadOpen:return this.client.request(e.threadOpen,p(e.threadOpen,r.params));case e.threadSnapshot:return this.client.request(e.threadSnapshot,p(e.threadSnapshot,r.params));case e.threadRename:return this.client.request(e.threadRename,p(e.threadRename,r.params));case e.threadDelete:return this.client.request(e.threadDelete,p(e.threadDelete,r.params));case e.threadDetach:return this.client.request(e.threadDetach,p(e.threadDetach,r.params));case e.threadCapabilities:return this.client.request(e.threadCapabilities,p(e.threadCapabilities,r.params));case e.turnStart:return this.startTurn(p(e.turnStart,r.params));case e.turnCancel:return this.client.request(e.turnCancel,p(e.turnCancel,r.params));case e.approvalRespond:return this.respondApproval(p(e.approvalRespond,r.params));case e.operationGet:return this.client.request(e.operationGet,p(e.operationGet,r.params))}}async closeIfIdle(){if(this.closed)return!0;if(!this.leases.canStopRuntime())return!1;this.closed=!0;try{this.releaseClientSubscription()}finally{this.releaseApprovalPolicyBinding?.()}return void 0===this.client.shutdown?this.client.close():await this.client.shutdown(),!0}handleEvent(e){this.updateLeases(e);const r=this.events.append(e);for(const e of this.eventListeners)try{e(r)}catch{}}updateLeases(e){if(void 0===e.turnId||"turn.completed"!==e.event.type&&"turn.cancelled"!==e.event.type&&"turn.failed"!==e.event.type){if(this.protocolCapabilities.clientApprovalResponses&&"approval.required"===e.event.type){const r=e.event.approval,a=this.approvals.get(r.id);a?.controller.abort(new Error(`Approval "${r.id}" was superseded`)),this.approvals.set(r.id,{approval:r,controller:new AbortController}),this.leases.acquire({kind:"approval",id:r.id})}}else if(this.leases.release({kind:"turn",id:e.turnId}),this.protocolCapabilities.clientApprovalResponses)for(const[r,a]of this.approvals)a.approval.turnId===e.turnId&&(this.approvals.delete(r),a.controller.abort(new Error(`Turn ended with ${e.event.type}`)),this.leases.release({kind:"approval",id:r}))}}export function isRollNodeClient(e){return"getOutcomeUnknownTurnIds"in e}
|
|
1
|
+
import{RUNTIME_METHODS as e,RUNTIME_SERVER_REQUEST_METHODS as r,getRuntimeProtocolCapabilities as t,parseRuntimeMethodParams as a}from"@roll-agent/protocol";import{z as o}from"zod";import{CompanionEventBuffer as i}from"./event-buffer.js";import{CompanionRelayFrameBuffer as n}from"./relay-frame-buffer.js";import{CompanionInteractionBroker as s}from"./interaction-broker.js";import{WorkspaceLeaseManager as l}from"./lease-manager.js";import{RELAY_REQUEST_METHODS as p,RELAY_REQUEST_METHODS_V11 as c,relayApprovalCandidateParamsSchema as d,relayInteractionCandidateParamsSchemaV11 as h}from"@roll-agent/relay-protocol";export const LOCAL_APPROVAL_DECISIONS=["allow","deny","require-local-confirmation"];export const localApprovalDecisionSchema=o.enum(LOCAL_APPROVAL_DECISIONS);export class LocalApprovalDeniedError extends Error{constructor(e){super(e),this.name="LocalApprovalDeniedError"}}export class LocalConfirmationRequiredError extends Error{constructor(e){super(e),this.name="LocalConfirmationRequiredError"}}export class InvalidRelayRequestParamsError extends Error{method;constructor(e){super(`Invalid params for Relay method "${e}"`),this.name="InvalidRelayRequestParamsError",this.method=e}}function u(e,r){try{return a(e,r)}catch{throw new InvalidRelayRequestParamsError(e)}}function v(e){const r=d.safeParse(e);if(!r.success)throw new InvalidRelayRequestParamsError(p.approvalCandidate);return r.data}function m(e){const r=h.safeParse(e);if(!r.success)throw new InvalidRelayRequestParamsError(c.interactionCandidate);return r.data}function w(e){const r=localApprovalDecisionSchema.safeParse(e);if(!r.success)throw new LocalApprovalDeniedError("Local Companion policy returned an invalid approval decision");return r.data}function y(e){return e instanceof Error?e:new Error(void 0===e?"Runtime cancelled the approval request":String(e))}async function f(e,r,t){if(r.aborted)throw y(r.reason);let a=()=>{};const o=new Promise((e,r)=>{a=r}),i=()=>{a(y(r.reason))};r.addEventListener("abort",i,{once:!0});try{const a=Promise.resolve().then(()=>{if(r.aborted)throw y(r.reason);return t(e,{signal:r})});let i;try{i=await Promise.race([a,o])}catch{if(r.aborted)throw y(r.reason);throw new Error("Local Companion policy failed")}return w(i)}finally{r.removeEventListener("abort",i)}}export class CompanionApprovalRequestBroker{leases=new l;handle=(e,r)=>this.handleApprovalRequest(e,r);pendingRequests=new Map;localApprovalPolicy;bindLocalApprovalPolicy(e){if(void 0!==this.localApprovalPolicy)throw new Error("Companion approval request broker is already bound to a workspace");this.localApprovalPolicy=e;let r=!1;return()=>{r||(r=!0,this.localApprovalPolicy===e&&(this.localApprovalPolicy=void 0))}}async submitCandidate(e){const r=this.pendingRequests.get(e.approvalId);if(void 0===r)throw new LocalApprovalDeniedError(`Approval "${e.approvalId}" is no longer pending`);if(r.params.threadId!==e.threadId||r.params.approval.turnId!==e.turnId)throw new LocalApprovalDeniedError(`Approval "${e.approvalId}" does not belong to the requested thread and turn`);if("reject"===e.decision)return r.activeApproveController?.abort(new LocalApprovalDeniedError(`Approval "${e.approvalId}" approval candidate was superseded by rejection`)),this.assertStillPending(r),this.resolvePending(r,{decision:"reject",...void 0!==e.reason?{reason:e.reason}:{}}),{accepted:!0};if(void 0!==r.activeApproveController)throw new LocalApprovalDeniedError(`Approval "${e.approvalId}" already has an approval candidate in progress`);const t=new AbortController;r.activeApproveController=t;try{const e=this.localApprovalPolicy;if(void 0===e)throw new LocalApprovalDeniedError("Companion approval request broker is not bound to a local policy");const a=await f(r.params.approval,AbortSignal.any([r.signal,t.signal]),e);if(this.assertStillPending(r),"deny"===a)throw this.resolvePending(r,{decision:"reject",reason:"Local Companion policy denied remote approval"}),new LocalApprovalDeniedError("Local Companion policy denied remote approval");if("require-local-confirmation"===a)throw new LocalConfirmationRequiredError("Local confirmation is required before this approval can continue");return this.assertStillPending(r),this.resolvePending(r,{decision:"approve"}),{accepted:!0}}catch(t){throw t instanceof LocalConfirmationRequiredError||this.pendingRequests.get(e.approvalId)!==r||this.rejectPending(r,t instanceof Error?t:new LocalApprovalDeniedError("Local Companion policy failed")),t}finally{r.activeApproveController===t&&(r.activeApproveController=void 0)}}handleApprovalRequest(e,r){if(r.signal.aborted)return Promise.reject(y(r.signal.reason));const t=this.pendingRequests.get(e.approval.id);if(void 0!==t)return Promise.reject(new Error(`Approval "${e.approval.id}" is already pending for Runtime request `+JSON.stringify(t.requestId)));let a=()=>{},o=()=>{};const i=new Promise((e,r)=>{a=e,o=r}),n={params:e,requestId:r.requestId,signal:r.signal,resolve:a,reject:o,releaseLease:this.leases.acquire({kind:"approval",id:e.approval.id}),activeApproveController:void 0},s=()=>{this.rejectPending(n,y(r.signal.reason))};return this.pendingRequests.set(e.approval.id,n),r.signal.addEventListener("abort",s,{once:!0}),r.signal.aborted&&s(),i.finally(()=>{r.signal.removeEventListener("abort",s)})}assertStillPending(e){if(this.pendingRequests.get(e.params.approval.id)!==e)throw new LocalApprovalDeniedError(`Approval "${e.params.approval.id}" is no longer pending`)}resolvePending(e,r){this.pendingRequests.delete(e.params.approval.id),e.releaseLease(),e.resolve(r)}rejectPending(e,r){this.pendingRequests.get(e.params.approval.id)===e&&(this.pendingRequests.delete(e.params.approval.id),e.releaseLease(),e.reject(r))}}export class CompanionWorkspace{leases;events;relayFramesV11;client;localApprovalPolicy;protocolCapabilities;interactionBroker;approvalRequestBroker;approvals=new Map;eventListeners=new Set;relayFrameListenersV11=new Set;releaseClientSubscription;releaseApprovalPolicyBinding;releaseInteractionBinding;closed=!1;constructor(e){this.client=e.client,this.localApprovalPolicy=e.localApprovalPolicy;const r=this.client.getInitializationResult?.().protocolVersion??"1.0";if(this.protocolCapabilities=t(r),this.interactionBroker=e.interactionBroker,this.approvalRequestBroker=e.approvalRequestBroker,void 0!==this.interactionBroker&&void 0!==this.approvalRequestBroker)throw new Error("Provide either interactionBroker or the deprecated approvalRequestBroker, not both");if(this.protocolCapabilities.serverRequestCapabilityNegotiation&&void 0===this.interactionBroker)throw new Error(`Runtime Protocol ${r} requires a CompanionInteractionBroker`);if(this.protocolCapabilities.serverRequests&&void 0===this.interactionBroker&&void 0===this.approvalRequestBroker)throw new Error("The negotiated Runtime Protocol requires a CompanionApprovalRequestBroker (deprecated) or CompanionInteractionBroker registered during client initialization");this.events=new i({...void 0!==e.maxEvents?{maxEvents:e.maxEvents}:{},...void 0!==e.maxBytes?{maxBytes:e.maxBytes}:{}}),this.relayFramesV11=new n({...void 0!==e.maxEvents?{maxEvents:e.maxEvents}:{},...void 0!==e.maxBytes?{maxBytes:e.maxBytes}:{}}),this.leases=this.interactionBroker?.leases??this.approvalRequestBroker?.leases??new l;const a=this.approvalRequestBroker?.bindLocalApprovalPolicy(this.localApprovalPolicy);let o;if(void 0!==this.interactionBroker){if(void 0===e.workspaceId)throw a?.(),new Error("workspaceId is required when interactionBroker is configured");o=this.interactionBroker.bindWorkspace({workspaceId:e.workspaceId,localApprovalPolicy:this.localApprovalPolicy,publish:e=>{const r=this.relayFramesV11.appendInteraction(e);this.publishRelayFrameV11(r)}})}try{this.releaseClientSubscription=this.client.onEvent(e=>this.handleEvent(e))}catch(e){throw o?.(),a?.(),e}this.releaseApprovalPolicyBinding=a,this.releaseInteractionBinding=o}attachBrowser(e){return this.leases.acquire({kind:"client",id:e})}detachBrowser(e){return this.leases.releaseClient(e)}acquireBackgroundShellLease(e){return this.leases.acquire({kind:"shell-session",id:e})}onBufferedEvent(e){return this.eventListeners.add(e),()=>{this.eventListeners.delete(e)}}onBufferedRelayFrameV11(e){return this.relayFrameListenersV11.add(e),()=>{this.relayFrameListenersV11.delete(e)}}replay(e=-1){return this.events.replay(e)}acknowledge(e){this.events.acknowledge(e)}replayRelayFramesV11(e=-1){return this.relayFramesV11.replay(e)}acknowledgeRelayFramesV11(e){this.relayFramesV11.acknowledge(e)}async startTurn(r){const t=a(e.turnStart,r),o=this.leases.acquire({kind:"turn",id:t.turnId});try{return await this.client.request(e.turnStart,t)}catch(e){throw o(),e}}async respondApproval(r){const t=a(e.approvalRespond,r);if(!this.protocolCapabilities.clientApprovalResponses)throw new LocalApprovalDeniedError("The negotiated Runtime Protocol must use the Relay approval.candidate method");const o=this.approvals.get(t.approvalId);if(void 0===o)throw new LocalApprovalDeniedError(`Approval "${t.approvalId}" is no longer pending`);if("approve"===t.decision){const e=await f(o.approval,o.controller.signal,this.localApprovalPolicy);if(o.controller.signal.aborted||this.approvals.get(t.approvalId)!==o)throw y(o.controller.signal.reason);if("deny"===e)throw new LocalApprovalDeniedError("Local Companion policy denied remote approval");if("require-local-confirmation"===e)throw new LocalConfirmationRequiredError("Local confirmation is required before this approval can continue")}const i=await this.client.request(e.approvalRespond,t);return this.approvals.get(t.approvalId)===o&&(o.controller.abort(new LocalApprovalDeniedError(`Approval "${t.approvalId}" was resolved by another response`)),this.approvals.delete(t.approvalId),this.leases.release({kind:"approval",id:t.approvalId})),i}async submitApprovalCandidate(e){const r=v(e);if(!this.protocolCapabilities.serverRequests)throw new LocalApprovalDeniedError("Relay approval.candidate requires Runtime server-request capability");if(void 0!==this.interactionBroker)return this.interactionBroker.submitLegacyApprovalCandidate(r);const t=this.approvalRequestBroker;if(void 0===t)throw new LocalApprovalDeniedError("Runtime server-request approval broker is unavailable");return t.submitCandidate(r)}async submitInteractionCandidateV11(e,r){const t=m(e),a=this.interactionBroker;if(void 0===a)throw new LocalApprovalDeniedError("Relay interaction.candidate requires CompanionInteractionBroker");return a.submitCandidate(t,r)}async handleRemoteRequest(r){switch(r.method){case p.approvalCandidate:return this.submitApprovalCandidate(v(r.params));case e.initialize:throw new LocalApprovalDeniedError("initialize is owned by the local Companion and cannot be relayed");case e.threadList:return this.client.request(e.threadList,u(e.threadList,r.params));case e.threadCreate:return this.client.request(e.threadCreate,u(e.threadCreate,r.params));case e.threadOpen:return this.client.request(e.threadOpen,u(e.threadOpen,r.params));case e.threadSnapshot:return this.client.request(e.threadSnapshot,u(e.threadSnapshot,r.params));case e.threadRename:return this.client.request(e.threadRename,u(e.threadRename,r.params));case e.threadDelete:return this.client.request(e.threadDelete,u(e.threadDelete,r.params));case e.threadDetach:return this.client.request(e.threadDetach,u(e.threadDetach,r.params));case e.threadCapabilities:return this.client.request(e.threadCapabilities,u(e.threadCapabilities,r.params));case e.turnStart:return this.startTurn(u(e.turnStart,r.params));case e.turnCancel:return this.client.request(e.turnCancel,u(e.turnCancel,r.params));case e.approvalRespond:return this.respondApproval(u(e.approvalRespond,r.params));case e.operationGet:return this.client.request(e.operationGet,u(e.operationGet,r.params))}}async handleRemoteRequestV11(r,t){switch(r.method){case c.interactionCandidate:return this.submitInteractionCandidateV11(m(r.params),t);case c.initialize:throw new LocalApprovalDeniedError("initialize is owned by the local Companion and cannot be relayed");case c.threadList:return this.client.request(e.threadList,u(e.threadList,r.params));case c.threadCreate:return this.client.request(e.threadCreate,u(e.threadCreate,r.params));case c.threadOpen:return this.client.request(e.threadOpen,u(e.threadOpen,r.params));case c.threadSnapshot:return this.client.request(e.threadSnapshot,u(e.threadSnapshot,r.params));case c.threadRename:return this.client.request(e.threadRename,u(e.threadRename,r.params));case c.threadDelete:return this.client.request(e.threadDelete,u(e.threadDelete,r.params));case c.threadDetach:return this.client.request(e.threadDetach,u(e.threadDetach,r.params));case c.threadCapabilities:return this.client.request(e.threadCapabilities,u(e.threadCapabilities,r.params));case c.turnStart:return this.startTurn(u(e.turnStart,r.params));case c.turnCancel:return this.client.request(e.turnCancel,u(e.turnCancel,r.params));case c.operationGet:return this.client.request(e.operationGet,u(e.operationGet,r.params))}}closeRemoteInteractions(e){this.interactionBroker?.close(e)}async closeIfIdle(){if(this.closed)return!0;if(!this.leases.canStopRuntime())return!1;this.closed=!0,this.interactionBroker?.close(new Error("Companion workspace closed"));try{this.releaseClientSubscription()}finally{this.releaseInteractionBinding?.(),this.releaseApprovalPolicyBinding?.()}return void 0===this.client.shutdown?this.client.close():await this.client.shutdown(),!0}handleEvent(e){this.updateLeases(e);const r=this.events.append(e);for(const e of this.eventListeners)try{e(r)}catch{}const t=this.relayFramesV11.appendRuntimeEvent(e);void 0!==t&&this.publishRelayFrameV11(t)}publishRelayFrameV11(e){for(const r of this.relayFrameListenersV11)try{r(e)}catch{}}updateLeases(e){if(void 0===e.turnId||"turn.completed"!==e.event.type&&"turn.cancelled"!==e.event.type&&"turn.failed"!==e.event.type){if(this.protocolCapabilities.clientApprovalResponses&&"approval.required"===e.event.type){const r=e.event.approval,t=this.approvals.get(r.id);t?.controller.abort(new Error(`Approval "${r.id}" was superseded`)),this.approvals.set(r.id,{approval:r,controller:new AbortController}),this.leases.acquire({kind:"approval",id:r.id})}}else if(this.leases.release({kind:"turn",id:e.turnId}),this.interactionBroker?.cancelTurn(e.threadId,e.turnId,new Error(`Turn ended with ${e.event.type}`)),this.protocolCapabilities.clientApprovalResponses)for(const[r,t]of this.approvals)t.approval.turnId===e.turnId&&(this.approvals.delete(r),t.controller.abort(new Error(`Turn ended with ${e.event.type}`)),this.leases.release({kind:"approval",id:r}))}}export function isRollNodeClient(e){return"getOutcomeUnknownTurnIds"in e}
|
package/dist/event-buffer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RuntimeEventEnvelope } from "@roll-agent/protocol";
|
|
2
2
|
export declare const DEFAULT_COMPANION_MAX_EVENTS = 10000;
|
|
3
3
|
export declare const DEFAULT_COMPANION_MAX_BYTES: number;
|
|
4
4
|
export interface BufferedRuntimeEvent {
|
package/dist/event-buffer.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DEFAULT_COMPANION_MAX_EVENTS=1e4;export const DEFAULT_COMPANION_MAX_BYTES=
|
|
1
|
+
import{RUNTIME_V13_DEFAULT_REPLAY_BUFFER_BYTES as e}from"@roll-agent/protocol";export const DEFAULT_COMPANION_MAX_EVENTS=1e4;export const DEFAULT_COMPANION_MAX_BYTES=e;export class CompanionEventBuffer{maxEvents;maxBytes;entries=[];nextSequence=0;retainedBytes=0;droppedThroughSequence=-1;constructor(e={}){if(this.maxEvents=e.maxEvents??1e4,this.maxBytes=e.maxBytes??DEFAULT_COMPANION_MAX_BYTES,!Number.isInteger(this.maxEvents)||this.maxEvents<1)throw new Error("maxEvents must be a positive integer");if(!Number.isInteger(this.maxBytes)||this.maxBytes<1)throw new Error("maxBytes must be a positive integer")}append(e){const t={relaySequence:this.nextSequence,event:e,bytes:Buffer.byteLength(JSON.stringify(e),"utf8")};return this.nextSequence+=1,this.entries.push(t),this.retainedBytes+=t.bytes,this.enforceLimits(),{relaySequence:t.relaySequence,event:e}}replay(e=-1){if(!Number.isInteger(e)||e<-1)throw new Error("afterRelaySequence must be an integer greater than or equal to -1");const t=e<this.droppedThroughSequence?{fromRelaySequence:e+1,throughRelaySequence:this.droppedThroughSequence}:void 0;return{events:this.entries.filter(t=>t.relaySequence>e).map(({relaySequence:e,event:t})=>({relaySequence:e,event:t})),gap:t}}acknowledge(e){if(!Number.isInteger(e)||e<-1)throw new Error("throughRelaySequence must be an integer greater than or equal to -1");if(!(e>this.highestRelaySequence))for(;void 0!==this.entries[0]&&this.entries[0].relaySequence<=e;){const e=this.entries.shift();void 0!==e&&(this.retainedBytes-=e.bytes,this.droppedThroughSequence=Math.max(this.droppedThroughSequence,e.relaySequence))}}get size(){return this.entries.length}get bytes(){return this.retainedBytes}get highestRelaySequence(){return this.nextSequence-1}enforceLimits(){for(;this.entries.length>this.maxEvents||this.retainedBytes>this.maxBytes&&this.entries.length>0;){const e=this.entries.shift();void 0!==e&&(this.retainedBytes-=e.bytes,this.droppedThroughSequence=e.relaySequence)}}}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,15 @@ export { COMPANION_RELAY_PROTOCOL_VERSION, RELAY_REQUEST_METHODS, deviceIdSchema
|
|
|
2
2
|
export type { DeviceId, WorkspaceId, RelayRequestId, RelayRequestMethod, RelayApprovalCandidateInput, RelayApprovalCandidateParams, RelayApprovalCandidateResult, RelayMessage, RelayRuntimeRequest, RelayRuntimeResponse, RelayRuntimeEvent, RelayGap, RelayEncryptedMessage, } from "./relay-protocol.ts";
|
|
3
3
|
export { CompanionEventBuffer, DEFAULT_COMPANION_MAX_BYTES, DEFAULT_COMPANION_MAX_EVENTS, } from "./event-buffer.ts";
|
|
4
4
|
export type { BufferedRuntimeEvent, CompanionEventBufferOptions, EventBufferGap, EventBufferReplay, } from "./event-buffer.ts";
|
|
5
|
+
export { CompanionRelayFrameBuffer, DEFAULT_COMPANION_RELAY_FRAME_MAX_BYTES, DEFAULT_COMPANION_RELAY_FRAME_MAX_EVENTS, materializeRelayFrameV11, } from "./relay-frame-buffer.ts";
|
|
6
|
+
export type { BufferedRelayInteractionFrameV11, BufferedRelayRuntimeEventV11, CompanionRelayFrameBufferOptions, CompanionRelayFrameEntryV11, CompanionRelayFrameReplayV11, RelayFrameBufferGapV11, } from "./relay-frame-buffer.ts";
|
|
5
7
|
export { COMPANION_LEASE_KINDS, WorkspaceLeaseManager } from "./lease-manager.ts";
|
|
6
8
|
export type { CompanionLease, CompanionLeaseKind } from "./lease-manager.ts";
|
|
9
|
+
export { CompanionInteractionBroker, createRuntimeServerRequestHandlers, } from "./interaction-broker.ts";
|
|
10
|
+
export type { CompanionInteractionBrokerOptions, CompanionInteractionFrameDraftV11, CompanionInteractionWorkspaceBinding, RemoteInteractionCandidateContext, RemoteInteractionResponderPolicy, RemoteInteractionResponderPolicyInput, } from "./interaction-broker.ts";
|
|
7
11
|
export { CompanionApprovalRequestBroker, CompanionWorkspace, InvalidRelayRequestParamsError, LOCAL_APPROVAL_DECISIONS, localApprovalDecisionSchema, LocalApprovalDeniedError, LocalConfirmationRequiredError, isRollNodeClient, } from "./companion-workspace.ts";
|
|
8
12
|
export { CompanionRelayBridge, OutboundCompanionRelay, createWebSocketRelayTransport, relayEventMessage, } from "./relay-bridge.ts";
|
|
13
|
+
export { CompanionRelayBridgeV11, OutboundCompanionRelayV11, createWebSocketRelayTransportV11, } from "./relay-bridge-v11.ts";
|
|
9
14
|
export type { CompanionRelayBridgeOptions, OutboundCompanionRelayOptions, RelayPayloadCipher, RelayTransport, WebSocketLike, WebSocketMessageEventLike, } from "./relay-bridge.ts";
|
|
15
|
+
export type { CompanionRelayBridgeV11Options, CompanionRelayConnectionV11Options, CompanionWorkspaceV11Port, OutboundCompanionRelayV11Connection, OutboundCompanionRelayV11Options, RelayPayloadCipherV11, RelayTransportV11, WebSocketLikeV11, WebSocketMessageEventLikeV11, } from "./relay-bridge-v11.ts";
|
|
10
16
|
export type { CompanionRuntimeClient, CompanionWorkspaceOptions, LocalApprovalDecision, LocalApprovalPolicy, LocalApprovalPolicyContext, } from "./companion-workspace.ts";
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{COMPANION_RELAY_PROTOCOL_VERSION,RELAY_REQUEST_METHODS,deviceIdSchema,workspaceIdSchema,relayRequestIdSchema,relayApprovalCandidateParamsSchema,relayApprovalCandidateResultSchema,relayDeviceConnectSchema,relayRuntimeRequestSchema,relayRuntimeResponseSchema,relayRuntimeEventSchema,relayAckSchema,relayGapSchema,relayEncryptedMessageSchema,relayMessageSchema}from"./relay-protocol.js";export{CompanionEventBuffer,DEFAULT_COMPANION_MAX_BYTES,DEFAULT_COMPANION_MAX_EVENTS}from"./event-buffer.js";export{COMPANION_LEASE_KINDS,WorkspaceLeaseManager}from"./lease-manager.js";export{CompanionApprovalRequestBroker,CompanionWorkspace,InvalidRelayRequestParamsError,LOCAL_APPROVAL_DECISIONS,localApprovalDecisionSchema,LocalApprovalDeniedError,LocalConfirmationRequiredError,isRollNodeClient}from"./companion-workspace.js";export{CompanionRelayBridge,OutboundCompanionRelay,createWebSocketRelayTransport,relayEventMessage}from"./relay-bridge.js";
|
|
1
|
+
export{COMPANION_RELAY_PROTOCOL_VERSION,RELAY_REQUEST_METHODS,deviceIdSchema,workspaceIdSchema,relayRequestIdSchema,relayApprovalCandidateParamsSchema,relayApprovalCandidateResultSchema,relayDeviceConnectSchema,relayRuntimeRequestSchema,relayRuntimeResponseSchema,relayRuntimeEventSchema,relayAckSchema,relayGapSchema,relayEncryptedMessageSchema,relayMessageSchema}from"./relay-protocol.js";export{CompanionEventBuffer,DEFAULT_COMPANION_MAX_BYTES,DEFAULT_COMPANION_MAX_EVENTS}from"./event-buffer.js";export{CompanionRelayFrameBuffer,DEFAULT_COMPANION_RELAY_FRAME_MAX_BYTES,DEFAULT_COMPANION_RELAY_FRAME_MAX_EVENTS,materializeRelayFrameV11}from"./relay-frame-buffer.js";export{COMPANION_LEASE_KINDS,WorkspaceLeaseManager}from"./lease-manager.js";export{CompanionInteractionBroker,createRuntimeServerRequestHandlers}from"./interaction-broker.js";export{CompanionApprovalRequestBroker,CompanionWorkspace,InvalidRelayRequestParamsError,LOCAL_APPROVAL_DECISIONS,localApprovalDecisionSchema,LocalApprovalDeniedError,LocalConfirmationRequiredError,isRollNodeClient}from"./companion-workspace.js";export{CompanionRelayBridge,OutboundCompanionRelay,createWebSocketRelayTransport,relayEventMessage}from"./relay-bridge.js";export{CompanionRelayBridgeV11,OutboundCompanionRelayV11,createWebSocketRelayTransportV11}from"./relay-bridge-v11.js";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { RUNTIME_SERVER_REQUEST_METHODS, type InteractionId, type ThreadId, type TurnId } from "@roll-agent/protocol";
|
|
2
|
+
import type { RuntimeServerRequestHandler, RuntimeServerRequestHandlers } from "@roll-agent/client-node";
|
|
3
|
+
import { type RelayApprovalCandidateParams, type RelayApprovalCandidateResult, type RelayInteractionCandidateParamsV11, type RelayInteractionCandidateResultV11, type RelayInteractionCancelledV11, type RelayInteractionMethodV11, type RelayInteractionRequestV11, type RelayInteractionResolvedV11, type RelayRequestId, type WorkspaceId } from "@roll-agent/relay-protocol";
|
|
4
|
+
import type { LocalApprovalPolicy } from "./companion-workspace.ts";
|
|
5
|
+
import { WorkspaceLeaseManager } from "./lease-manager.ts";
|
|
6
|
+
type RelaySequenceFields = "workspaceId" | "relaySequence";
|
|
7
|
+
type WithoutRelaySequence<T> = T extends unknown ? Omit<T, RelaySequenceFields> : never;
|
|
8
|
+
/**
|
|
9
|
+
* A safe Relay Wire 1.1 interaction frame before a workspace sequence is assigned.
|
|
10
|
+
*
|
|
11
|
+
* The union is deliberately derived from the public Relay contract. Runtime JSON-RPC ids,
|
|
12
|
+
* approval previews, tool input/output, and full interaction results cannot be represented here.
|
|
13
|
+
*/
|
|
14
|
+
export type CompanionInteractionFrameDraftV11 = WithoutRelaySequence<RelayInteractionRequestV11> | WithoutRelaySequence<RelayInteractionResolvedV11> | WithoutRelaySequence<RelayInteractionCancelledV11>;
|
|
15
|
+
export interface CompanionInteractionWorkspaceBinding {
|
|
16
|
+
readonly workspaceId: WorkspaceId;
|
|
17
|
+
readonly localApprovalPolicy: LocalApprovalPolicy;
|
|
18
|
+
readonly publish: (frame: CompanionInteractionFrameDraftV11) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface RemoteInteractionResponderPolicyInput {
|
|
21
|
+
readonly workspaceId: WorkspaceId;
|
|
22
|
+
readonly requestId: RelayRequestId;
|
|
23
|
+
readonly interactionId: InteractionId;
|
|
24
|
+
readonly threadId: ThreadId;
|
|
25
|
+
readonly turnId: TurnId;
|
|
26
|
+
readonly method: RelayInteractionMethodV11;
|
|
27
|
+
readonly responderContext: unknown;
|
|
28
|
+
/** Aborted when the Relay connection generation that supplied this candidate becomes stale. */
|
|
29
|
+
readonly signal: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
export type RemoteInteractionResponderPolicy = (input: RemoteInteractionResponderPolicyInput) => boolean | Promise<boolean>;
|
|
32
|
+
export interface RemoteInteractionCandidateContext {
|
|
33
|
+
readonly workspaceId: WorkspaceId;
|
|
34
|
+
readonly requestId: RelayRequestId;
|
|
35
|
+
/** Signal owned by one Relay connection generation, not by the logical Runtime interaction. */
|
|
36
|
+
readonly signal: AbortSignal;
|
|
37
|
+
readonly responderPolicy: RemoteInteractionResponderPolicy;
|
|
38
|
+
readonly responderContext: unknown;
|
|
39
|
+
}
|
|
40
|
+
export interface CompanionInteractionBrokerOptions {
|
|
41
|
+
/** Runtime 1.1 does not carry a deadline; this local bound is used for its Approval facade. */
|
|
42
|
+
readonly legacyApprovalTimeoutMs?: number;
|
|
43
|
+
readonly now?: () => number;
|
|
44
|
+
readonly createInteractionId?: () => InteractionId;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Owns Runtime server-request interactions while exposing only safe Relay Wire projections.
|
|
48
|
+
* One instance may be bound to one workspace for its entire active lifetime.
|
|
49
|
+
*/
|
|
50
|
+
export declare class CompanionInteractionBroker {
|
|
51
|
+
readonly leases: WorkspaceLeaseManager;
|
|
52
|
+
readonly handleApprovalRequest: RuntimeServerRequestHandler<typeof RUNTIME_SERVER_REQUEST_METHODS.approvalRequest>;
|
|
53
|
+
readonly handleUserInputRequest: RuntimeServerRequestHandler<typeof RUNTIME_SERVER_REQUEST_METHODS.userInputRequest>;
|
|
54
|
+
private readonly pending;
|
|
55
|
+
private readonly approvalInteractions;
|
|
56
|
+
private readonly legacyApprovalTimeoutMs;
|
|
57
|
+
private readonly now;
|
|
58
|
+
private readonly createInteractionId;
|
|
59
|
+
private workspace;
|
|
60
|
+
private closed;
|
|
61
|
+
constructor(options?: CompanionInteractionBrokerOptions);
|
|
62
|
+
bindWorkspace(binding: CompanionInteractionWorkspaceBinding): () => void;
|
|
63
|
+
submitCandidate(input: RelayInteractionCandidateParamsV11, context: RemoteInteractionCandidateContext): Promise<RelayInteractionCandidateResultV11>;
|
|
64
|
+
submitLegacyApprovalCandidate(input: RelayApprovalCandidateParams): Promise<RelayApprovalCandidateResult>;
|
|
65
|
+
cancelTurn(threadId: ThreadId, turnId: TurnId, reason?: unknown): number;
|
|
66
|
+
close(reason?: unknown): void;
|
|
67
|
+
private registerApproval;
|
|
68
|
+
private registerUserInput;
|
|
69
|
+
private createPendingBase;
|
|
70
|
+
private activatePending;
|
|
71
|
+
private scheduleDeadline;
|
|
72
|
+
private authorizeResponder;
|
|
73
|
+
private approveCandidate;
|
|
74
|
+
private finishResolved;
|
|
75
|
+
private finishCancelled;
|
|
76
|
+
private finish;
|
|
77
|
+
private cancelAll;
|
|
78
|
+
private clearCandidateAttempt;
|
|
79
|
+
private requireWorkspace;
|
|
80
|
+
private nextInteractionId;
|
|
81
|
+
private assertCandidateIdentity;
|
|
82
|
+
private assertStillPending;
|
|
83
|
+
private isStillPending;
|
|
84
|
+
}
|
|
85
|
+
export declare function createRuntimeServerRequestHandlers(broker: CompanionInteractionBroker): RuntimeServerRequestHandlers;
|
|
86
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{randomUUID as e}from"node:crypto";import{RUNTIME_SERVER_REQUEST_METHODS as t,approvalRequestParamsV11Schema as r,approvalRequestParamsV12Schema as n,approvalRequestResultSchema as i,getApprovalExplanation as a,interactionIdSchema as o,normalizeUserInputResult as s,userInputFormSchema as d,userInputRequestParamsV12Schema as c,userInputResultSchema as l}from"@roll-agent/protocol";import{RELAY_INTERACTION_METHODS_V11 as p,RELAY_MESSAGE_TYPES_V11 as h,relayApprovalCandidateParamsSchema as u,relayInteractionCandidateParamsSchemaV11 as v,relayRequestIdSchema as m,workspaceIdSchema as I}from"@roll-agent/relay-protocol";import{z as g}from"zod/v4";import{WorkspaceLeaseManager as f}from"./lease-manager.js";const w=3e5,y=2147483647,b=g.object({interactionId:o,threadId:g.string().uuid().brand(),turnId:g.string().uuid().brand(),method:g.enum([p.approvalRequest,p.userInputRequest])}).passthrough().readonly();function C(e,t){return e instanceof Error?e:new Error(void 0===e?t:String(e))}function R(e,t){const r=new Error(t);return r.name=e,r}function A(e){return R("LocalApprovalDeniedError",e)}function q(e){return R("LocalConfirmationRequiredError",e)}function k(e){return R("RemoteInteractionResponderDeniedError",e)}function P(e){const t=C(e,"The Relay connection generation is no longer active");return"Error"===t.name&&(t.name="RemoteInteractionCandidateAbortedError"),t}function S(e,t){const r=C(e,t);return"Error"===r.name&&(r.name="CompanionInteractionCancelledError"),r}function L(e){if("allow"===e||"deny"===e||"require-local-confirmation"===e)return e;throw A("Local Companion policy returned an invalid approval decision")}async function j(e,t,r){if(t.aborted)throw C(t.reason,r);const n=Promise.withResolvers(),i=()=>{n.reject(C(t.reason,r))};t.addEventListener("abort",i,{once:!0});try{return await Promise.race([Promise.resolve().then(e),n.promise])}finally{t.removeEventListener("abort",i)}}function x(e){try{return JSON.stringify(e)??"undefined"}catch{return"unserializable"}}function E(e){return"interactionId"in e}export class CompanionInteractionBroker{leases=new f;handleApprovalRequest=(e,t)=>this.registerApproval(e,t);handleUserInputRequest=(e,t)=>this.registerUserInput(e,t);pending=new Map;approvalInteractions=new Map;legacyApprovalTimeoutMs;now;createInteractionId;workspace;closed=!1;constructor(t={}){const r=t.legacyApprovalTimeoutMs??w;if(!Number.isSafeInteger(r)||r<=0)throw new Error("legacyApprovalTimeoutMs must be a positive safe integer");this.legacyApprovalTimeoutMs=r,this.now=t.now??Date.now,this.createInteractionId=t.createInteractionId??(()=>o.parse(e()))}bindWorkspace(e){if(this.closed)throw new Error("Companion interaction broker is closed");if(void 0!==this.workspace)throw new Error("Companion interaction broker is already bound to a workspace");const t={workspaceId:I.parse(e.workspaceId),localApprovalPolicy:e.localApprovalPolicy,publish:e.publish};if("function"!=typeof t.localApprovalPolicy||"function"!=typeof t.publish)throw new TypeError("Companion workspace binding requires policy and publish functions");this.workspace=t;let r=!1;return()=>{if(r)return;if(r=!0,this.workspace!==t)return;const e=S(void 0,"Companion interaction workspace binding was released");this.cancelAll(e),this.workspace=void 0}}async submitCandidate(e,t){const r=b.parse(e),n=this.requireWorkspace(),a=I.parse(t.workspaceId),o=m.parse(t.requestId);if(a!==n.workspaceId)throw k("Remote interaction candidate belongs to another workspace");const d=this.pending.get(r.interactionId);if(this.assertCandidateIdentity(d,r),t.signal.aborted)throw P(t.signal.reason);const c=x(e);let h=d.activeCandidate;if(!0===h?.generationSignal?.aborted&&(h.controller.abort(P(h.generationSignal.reason)),d.activeCandidate===h&&(d.activeCandidate=void 0),h=void 0),void 0!==h&&h.key===o){if(h.fingerprint!==c)throw k(`Relay request "${o}" conflicts with its active candidate`);return h.result}const u=async r=>{await this.authorizeResponder(d,t,o,r);const n=v.parse(e);if(this.assertStillPending(d),void 0!==d.activeCandidate&&d.activeCandidate.key!==o&&(n.method!==p.approvalRequest||"reject"!==n.candidate.decision))throw k(`Interaction "${d.interactionId}" already has a candidate in progress`);if(n.method===p.userInputRequest){if(d.method!==n.method)throw k("Remote interaction method no longer matches the pending request");const e=s(d.params,n.candidate);return this.finishResolved(d,l.parse(e)),{accepted:!0}}if(d.method!==n.method)throw k("Remote interaction method no longer matches the pending request");return"reject"===n.candidate.decision?(d.activeCandidate?.controller.abort(A("A remote rejection superseded the approval candidate")),this.assertStillPending(d),this.finishResolved(d,i.parse(n.candidate)),{accepted:!0}):this.approveCandidate(d,n.candidate,t.signal)};if(void 0!==h)return u(h.controller.signal);const g=new AbortController,f=u(g.signal),w={key:o,fingerprint:c,controller:g,generationSignal:t.signal,result:f};return d.activeCandidate=w,f.then(()=>this.clearCandidateAttempt(d,w),()=>this.clearCandidateAttempt(d,w)),f}async submitLegacyApprovalCandidate(e){const t=u.parse(e),r=this.approvalInteractions.get(t.approvalId),n=void 0===r?void 0:this.pending.get(r);if(void 0===n||n.method!==p.approvalRequest)throw A(`Approval "${t.approvalId}" is no longer pending`);if(n.threadId!==t.threadId||n.turnId!==t.turnId)throw A(`Approval "${t.approvalId}" does not belong to the requested thread and turn`);if("reject"===t.decision)return n.activeCandidate?.controller.abort(A("A legacy rejection superseded the approval candidate")),this.assertStillPending(n),this.finishResolved(n,i.parse({decision:"reject",...void 0===t.reason?{}:{reason:t.reason}})),{accepted:!0};if(void 0!==n.activeCandidate)throw A(`Approval "${t.approvalId}" already has a candidate in progress`);const a=new AbortController,o=this.approveCandidate(n,{decision:"approve"},a.signal).then(()=>({accepted:!0})),s={key:`legacy:${t.approvalId}`,fingerprint:x(t),controller:a,generationSignal:void 0,result:o};return n.activeCandidate=s,o.then(()=>this.clearCandidateAttempt(n,s),()=>this.clearCandidateAttempt(n,s)),o}cancelTurn(e,t,r){let n=0;const i=S(r,"The Runtime turn reached a terminal state");for(const r of[...this.pending.values()])r.threadId===e&&r.turnId===t&&this.finishCancelled(r,i)&&(n+=1);return n}close(e){this.closed||(this.closed=!0,this.cancelAll(S(e,"Companion interaction broker was closed")),this.workspace=void 0)}registerApproval(e,t){let o;try{o="interactionId"in e?n.parse(e):r.parse(e)}catch(e){return Promise.reject(C(e,"Invalid Runtime approval request"))}const s=E(o)?o.interactionId:this.nextInteractionId(),d=E(o)?o.expiresAt:o.expiresAt??new Date(this.now()+this.legacyApprovalTimeoutMs).toISOString(),c=o.threadId,l=o.approval.turnId,u=o.approval.id;if(this.approvalInteractions.has(u))return Promise.reject(A(`Approval "${u}" is already pending`));const v=Promise.withResolvers(),m=this.createPendingBase({method:p.approvalRequest,params:o,approval:o.approval,approvalId:u,interactionId:s,threadId:c,turnId:l,expiresAt:d,releaseLease:this.leases.acquire({kind:"approval",id:u})},t,e=>v.resolve(i.parse(e)),v.reject),I=a(o.approval),g={type:h.interactionRequest,interactionId:s,threadId:c,turnId:l,expiresAt:d,sensitivity:"normal",method:p.approvalRequest,projection:{approvalId:u,agentName:o.approval.agentName,toolName:o.approval.toolName,...void 0===I?{}:{explanation:I}}};return this.approvalInteractions.set(u,s),this.activatePending(m,g),v.promise}registerUserInput(e,t){let r;try{r=c.parse(e)}catch(e){return Promise.reject(C(e,"Invalid Runtime user input request"))}const n=Promise.withResolvers(),i=this.createPendingBase({method:p.userInputRequest,params:r,interactionId:r.interactionId,threadId:r.threadId,turnId:r.turnId,expiresAt:r.expiresAt,releaseLease:this.leases.acquire({kind:"turn",id:r.turnId})},t,e=>n.resolve(l.parse(e)),n.reject),a=d.parse({...void 0===r.title?{}:{title:r.title},...void 0===r.description?{}:{description:r.description},controls:r.controls}),o={type:h.interactionRequest,interactionId:r.interactionId,threadId:r.threadId,turnId:r.turnId,expiresAt:r.expiresAt,sensitivity:r.sensitivity,method:p.userInputRequest,projection:a};return this.activatePending(i,o),n.promise}createPendingBase(e,t,r,n){const i=()=>{this.finishCancelled(a,S(t.signal.reason,"Runtime cancelled the interaction"))},a={...e,runtimeSignal:t.signal,resolve:r,reject:n,removeRuntimeAbortListener:()=>{t.signal.removeEventListener("abort",i)},deadlineTimer:void 0,requestPublished:!1,activeCandidate:void 0};return t.signal.addEventListener("abort",i,{once:!0}),a}activatePending(e,t){let r;try{r=this.requireWorkspace()}catch(t){return e.removeRuntimeAbortListener(),e.releaseLease(),e.method===p.approvalRequest&&this.approvalInteractions.delete(e.approvalId),void e.reject(C(t,"Companion interaction broker is unavailable"))}if(this.pending.has(e.interactionId))return e.removeRuntimeAbortListener(),e.releaseLease(),e.method===p.approvalRequest&&this.approvalInteractions.delete(e.approvalId),void e.reject(k(`Interaction "${e.interactionId}" is already pending`));if(Date.parse(e.expiresAt)<=this.now())return e.removeRuntimeAbortListener(),e.releaseLease(),e.method===p.approvalRequest&&this.approvalInteractions.delete(e.approvalId),void e.reject(S(void 0,`Interaction expired at ${e.expiresAt}`));if(this.pending.set(e.interactionId,e),e.runtimeSignal.aborted)this.finishCancelled(e,S(e.runtimeSignal.reason,"Runtime cancelled the interaction"));else{try{e.requestPublished=!0,r.publish(t)}catch(t){return e.requestPublished=!1,void this.finishCancelled(e,S(t,"Failed to publish the interaction request"))}e.runtimeSignal.aborted?this.finishCancelled(e,S(e.runtimeSignal.reason,"Runtime cancelled the interaction")):this.scheduleDeadline(e)}}scheduleDeadline(e){const t=Date.parse(e.expiresAt)-this.now();t<=0?this.finishCancelled(e,S(void 0,`Interaction expired at ${e.expiresAt}`)):e.deadlineTimer=setTimeout(()=>this.scheduleDeadline(e),Math.min(t,y))}async authorizeResponder(e,t,r,n){if("function"!=typeof t.responderPolicy)throw k("Remote interaction candidate has no responder policy");const i=AbortSignal.any([e.runtimeSignal,t.signal,...void 0===n?[]:[n]]);let a;try{a=await j(()=>t.responderPolicy({workspaceId:t.workspaceId,requestId:r,interactionId:e.interactionId,threadId:e.threadId,turnId:e.turnId,method:e.method,responderContext:t.responderContext,signal:i}),i,"Remote responder policy was aborted")}catch(r){if(t.signal.aborted)throw P(t.signal.reason);if(!0===n?.aborted)throw P(n.reason);if(!this.isStillPending(e))throw S(r,"Interaction is no longer pending");throw k("Remote responder policy failed closed")}if(t.signal.aborted)throw P(t.signal.reason);if(this.assertStillPending(e),!0!==a)throw k("Remote responder policy denied the interaction candidate")}async approveCandidate(e,t,r){const n=this.requireWorkspace(),a=e.activeCandidate?.controller,o=AbortSignal.any([e.runtimeSignal,r,...void 0===a?[]:[a.signal]]);let s;try{s=L(await j(()=>n.localApprovalPolicy(e.approval,{signal:o}),o,"Local Companion policy was aborted"))}catch(t){if(r.aborted)throw P(r.reason);if(!this.isStillPending(e))throw S(t,"Approval is no longer pending");const n=A("Local Companion policy failed");throw this.finishCancelled(e,n),n}if(r.aborted)throw P(r.reason);if(this.assertStillPending(e),"deny"===s){const t=A("Local Companion policy denied remote approval");throw this.finishResolved(e,i.parse({decision:"reject",reason:"Local Companion policy denied remote approval"})),t}if("require-local-confirmation"===s)throw q("Local confirmation is required before this approval can continue");return this.finishResolved(e,i.parse(t)),{accepted:!0}}finishResolved(e,t){return this.finish(e,{type:h.interactionResolved,result:t})}finishCancelled(e,t){return this.finish(e,{type:h.interactionCancelled,error:t})}finish(e,t){if(!this.isStillPending(e))return!1;if(this.pending.delete(e.interactionId),e.method===p.approvalRequest&&this.approvalInteractions.delete(e.approvalId),void 0!==e.deadlineTimer&&(clearTimeout(e.deadlineTimer),e.deadlineTimer=void 0),e.removeRuntimeAbortListener(),e.releaseLease(),e.activeCandidate?.controller.abort(t.type===h.interactionCancelled?t.error:S(void 0,"Interaction was resolved")),e.requestPublished){const r=this.workspace;try{r?.publish({type:t.type,interactionId:e.interactionId,threadId:e.threadId,turnId:e.turnId,method:e.method})}catch{}}return t.type===h.interactionResolved?e.resolve(t.result):e.reject(t.error),!0}cancelAll(e){for(const t of[...this.pending.values()])this.finishCancelled(t,e)}clearCandidateAttempt(e,t){e.activeCandidate===t&&(e.activeCandidate=void 0)}requireWorkspace(){if(this.closed)throw S(void 0,"Companion interaction broker is closed");const e=this.workspace;if(void 0===e)throw A("Companion interaction broker is not bound to a workspace");return e}nextInteractionId(){for(let e=0;e<8;e+=1){const e=o.parse(this.createInteractionId());if(!this.pending.has(e))return e}throw new Error("Unable to allocate a unique interaction id")}assertCandidateIdentity(e,t){if(void 0===e)throw k(`Interaction "${t.interactionId}" is no longer pending`);if(e.threadId!==t.threadId||e.turnId!==t.turnId||e.method!==t.method)throw k(`Interaction "${t.interactionId}" does not match its thread, turn, and method`)}assertStillPending(e){if(!this.isStillPending(e))throw k(`Interaction "${e.interactionId}" is no longer pending`)}isStillPending(e){return this.pending.get(e.interactionId)===e}}export function createRuntimeServerRequestHandlers(e){return{[t.approvalRequest]:e.handleApprovalRequest,[t.userInputRequest]:e.handleUserInputRequest}}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type JsonValue } from "@roll-agent/protocol";
|
|
2
|
+
import { type DeviceId, type RelayEncryptedMessageV11, type RelayMessageV11, type RelayRuntimeRequestV11, type WorkspaceId } from "@roll-agent/relay-protocol";
|
|
3
|
+
import type { RemoteInteractionCandidateContext, RemoteInteractionResponderPolicy } from "./interaction-broker.ts";
|
|
4
|
+
import { type CompanionRelayFrameEntryV11, type CompanionRelayFrameReplayV11 } from "./relay-frame-buffer.ts";
|
|
5
|
+
export interface RelayTransportV11 {
|
|
6
|
+
send(message: RelayMessageV11): void | Promise<void>;
|
|
7
|
+
onMessage(listener: (message: unknown) => void): () => void;
|
|
8
|
+
onClose(listener: () => void): () => void;
|
|
9
|
+
close(): void;
|
|
10
|
+
}
|
|
11
|
+
export interface RelayPayloadCipherV11 {
|
|
12
|
+
readonly algorithm: string;
|
|
13
|
+
encrypt(value: JsonValue): Promise<{
|
|
14
|
+
readonly nonce: string;
|
|
15
|
+
readonly ciphertext: string;
|
|
16
|
+
}>;
|
|
17
|
+
decrypt(message: RelayEncryptedMessageV11): Promise<JsonValue>;
|
|
18
|
+
}
|
|
19
|
+
export interface CompanionWorkspaceV11Port {
|
|
20
|
+
onBufferedRelayFrameV11(listener: (entry: CompanionRelayFrameEntryV11) => void): () => void;
|
|
21
|
+
replayRelayFramesV11(afterRelaySequence?: number): CompanionRelayFrameReplayV11;
|
|
22
|
+
acknowledgeRelayFramesV11(throughRelaySequence: number): void;
|
|
23
|
+
handleRemoteRequestV11(request: RelayRuntimeRequestV11, context: RemoteInteractionCandidateContext): Promise<unknown>;
|
|
24
|
+
closeRemoteInteractions?(reason: Error): void;
|
|
25
|
+
}
|
|
26
|
+
export interface CompanionRelayBridgeV11Options {
|
|
27
|
+
readonly deviceId: DeviceId;
|
|
28
|
+
readonly pairingToken: string;
|
|
29
|
+
readonly workspaces: ReadonlyMap<WorkspaceId, CompanionWorkspaceV11Port>;
|
|
30
|
+
readonly ciphers?: ReadonlyMap<WorkspaceId, RelayPayloadCipherV11>;
|
|
31
|
+
readonly maxRequestCacheEntries?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CompanionRelayConnectionV11Options {
|
|
34
|
+
readonly responderPolicy: RemoteInteractionResponderPolicy;
|
|
35
|
+
/**
|
|
36
|
+
* Opaque host-owned authentication/session state. The Companion never interprets this value
|
|
37
|
+
* and does not claim that its presence authenticates a Browser or elects a controller.
|
|
38
|
+
*/
|
|
39
|
+
readonly responderContext: unknown;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Explicit Relay Wire 1.1 bridge. The legacy `CompanionRelayBridge.connect(transport)` API remains
|
|
43
|
+
* a frozen Wire 1.0 path; callers must opt into this class to project typed Interactions.
|
|
44
|
+
*/
|
|
45
|
+
export declare class CompanionRelayBridgeV11 {
|
|
46
|
+
private readonly deviceId;
|
|
47
|
+
private readonly pairingToken;
|
|
48
|
+
private readonly workspaces;
|
|
49
|
+
private readonly ciphers;
|
|
50
|
+
private readonly maxRequestCacheEntries;
|
|
51
|
+
private readonly acknowledged;
|
|
52
|
+
private readonly inFlightRequestCache;
|
|
53
|
+
private readonly settledRequestCache;
|
|
54
|
+
private readonly releaseWorkspaceSubscriptions;
|
|
55
|
+
private generation;
|
|
56
|
+
private releaseTransportSubscriptions;
|
|
57
|
+
private closed;
|
|
58
|
+
constructor(options: CompanionRelayBridgeV11Options);
|
|
59
|
+
connect(transport: RelayTransportV11, options: CompanionRelayConnectionV11Options): void;
|
|
60
|
+
close(): void;
|
|
61
|
+
private enqueue;
|
|
62
|
+
private enqueueTask;
|
|
63
|
+
private failGeneration;
|
|
64
|
+
private handleMessage;
|
|
65
|
+
private resolveRuntimeRequest;
|
|
66
|
+
private executeRuntimeRequest;
|
|
67
|
+
private settleRequest;
|
|
68
|
+
private sendFrame;
|
|
69
|
+
private sendRuntimeResponse;
|
|
70
|
+
private releaseCurrentGeneration;
|
|
71
|
+
private markMessageAdvertised;
|
|
72
|
+
private markAdvertised;
|
|
73
|
+
}
|
|
74
|
+
export interface WebSocketMessageEventLikeV11 {
|
|
75
|
+
readonly data: unknown;
|
|
76
|
+
}
|
|
77
|
+
export interface WebSocketLikeV11 {
|
|
78
|
+
send(data: string): void;
|
|
79
|
+
close(): void;
|
|
80
|
+
addEventListener(type: "message", listener: (event: WebSocketMessageEventLikeV11) => void): void;
|
|
81
|
+
addEventListener(type: "close", listener: () => void): void;
|
|
82
|
+
removeEventListener(type: "message", listener: (event: WebSocketMessageEventLikeV11) => void): void;
|
|
83
|
+
removeEventListener(type: "close", listener: () => void): void;
|
|
84
|
+
}
|
|
85
|
+
export declare function createWebSocketRelayTransportV11(socket: WebSocketLikeV11): RelayTransportV11;
|
|
86
|
+
export interface OutboundCompanionRelayV11Connection {
|
|
87
|
+
readonly transport: RelayTransportV11;
|
|
88
|
+
readonly responderPolicy: RemoteInteractionResponderPolicy;
|
|
89
|
+
readonly responderContext: unknown;
|
|
90
|
+
}
|
|
91
|
+
export interface OutboundCompanionRelayV11Options {
|
|
92
|
+
readonly bridge: CompanionRelayBridgeV11;
|
|
93
|
+
readonly connectTransport: () => Promise<OutboundCompanionRelayV11Connection>;
|
|
94
|
+
readonly minReconnectMs?: number;
|
|
95
|
+
readonly maxReconnectMs?: number;
|
|
96
|
+
}
|
|
97
|
+
export declare class OutboundCompanionRelayV11 {
|
|
98
|
+
private readonly bridge;
|
|
99
|
+
private readonly connectTransport;
|
|
100
|
+
private readonly minReconnectMs;
|
|
101
|
+
private readonly maxReconnectMs;
|
|
102
|
+
private reconnectMs;
|
|
103
|
+
private timer;
|
|
104
|
+
private transport;
|
|
105
|
+
private stopped;
|
|
106
|
+
private connecting;
|
|
107
|
+
constructor(options: OutboundCompanionRelayV11Options);
|
|
108
|
+
start(): void;
|
|
109
|
+
stop(): void;
|
|
110
|
+
private connect;
|
|
111
|
+
private scheduleReconnect;
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash as e,randomUUID as t}from"node:crypto";import{RollRpcError as r}from"@roll-agent/client-node";import{jsonValueSchema as s}from"@roll-agent/protocol";import{RELAY_ERROR_CODES as n,RELAY_MESSAGE_TYPES_V11 as o,RELAY_REQUEST_METHODS_V11 as i,canonicalizeRelayJson as a,classifyRelayAck as c,getRelayErrorRetryability as d,getRelayRequestMethodDispositionForVersion as p,parseRelayRequestParamsForVersion as h,parseRelayRequestResultForVersion as u,projectRelayOperationGetResultV11 as l,projectRelayThreadSnapshotV11 as m,relayEnvelopeIdSchema as y,relayMessageSchemaV11 as R,relayRuntimeRequestSchemaV11 as g}from"@roll-agent/relay-protocol";import{LocalApprovalDeniedError as q,LocalConfirmationRequiredError as f}from"./companion-workspace.js";import{materializeRelayFrameV11 as w}from"./relay-frame-buffer.js";class InvalidRelayRequestParamsV11Error extends Error{constructor(){super("Invalid Relay Wire 1.1 request params"),this.name="InvalidRelayRequestParamsV11Error"}}class LocalOnlyRelayRequestV11Error extends Error{constructor(e){super(`Relay method "${e}" is owned by the local Companion`),this.name="LocalOnlyRelayRequestV11Error"}}const v=1e4,k={code:n.encryptionRequired,message:"Encrypted Relay request required for this workspace",retryable:d(n.encryptionRequired)};function I(t){const r={workspaceId:t.workspaceId,method:t.method,params:t.params};return e("sha256").update(a(r)).digest("hex")}function C(e,t){let r=t;return e.method===i.threadOpen||e.method===i.threadSnapshot?r=m(t):e.method===i.operationGet&&(r=l(t)),s.parse(u("1.1",e.method,r))}function E(e){if(e instanceof InvalidRelayRequestParamsV11Error)return{code:n.invalidParams,message:"Invalid Relay request params",retryable:d(n.invalidParams)};if(e instanceof r)return{code:e.data?.rollCode??`JSON_RPC_${String(e.code)}`,message:"Runtime request failed",retryable:e.data?.retryable??!1};if(e instanceof q||e instanceof f||e instanceof Error&&("LocalApprovalDeniedError"===e.name||"LocalConfirmationRequiredError"===e.name)){const t=e instanceof q||"LocalApprovalDeniedError"===e.name?n.localApprovalDenied:n.localConfirmationRequired;return{code:t,message:t===n.localApprovalDenied?"Local approval denied":"Local confirmation required",retryable:d(t)}}return{code:n.companionError,message:"Companion request failed",retryable:d(n.companionError)}}export class CompanionRelayBridgeV11{deviceId;pairingToken;workspaces;ciphers;maxRequestCacheEntries;acknowledged=new Map;inFlightRequestCache=new Map;settledRequestCache=new Map;releaseWorkspaceSubscriptions;generation;releaseTransportSubscriptions=[];closed=!1;constructor(e){if(this.deviceId=e.deviceId,this.pairingToken=e.pairingToken,this.workspaces=e.workspaces,this.ciphers=e.ciphers??new Map,this.maxRequestCacheEntries=e.maxRequestCacheEntries??v,!Number.isInteger(this.maxRequestCacheEntries)||this.maxRequestCacheEntries<1)throw new Error("maxRequestCacheEntries must be a positive integer");this.releaseWorkspaceSubscriptions=[...this.workspaces.entries()].map(([e,t])=>t.onBufferedRelayFrameV11(t=>{const r=this.generation;void 0!==r&&this.sendFrame(r,e,t)}))}connect(e,t){if(this.closed)throw new Error("Companion Relay Wire 1.1 bridge is closed");this.releaseCurrentGeneration(!0);const r={transport:e,responderPolicy:t.responderPolicy,responderContext:t.responderContext,controller:new AbortController,advertisedThrough:new Map,queue:Promise.resolve()};this.generation=r,this.releaseTransportSubscriptions=[e.onMessage(e=>{this.handleMessage(e,r).catch(()=>{})}),e.onClose(()=>{this.generation===r&&this.releaseCurrentGeneration()})],this.enqueue(r,{type:o.deviceConnect,protocolVersion:"1.1",deviceId:this.deviceId,pairingToken:this.pairingToken});for(const[e,t]of this.workspaces){const s=t.replayRelayFramesV11(this.acknowledged.get(e)??-1);void 0!==s.gap&&this.enqueue(r,{type:o.runtimeGap,workspaceId:e,fromRelaySequence:s.gap.fromRelaySequence,throughRelaySequence:s.gap.throughRelaySequence,recovery:"thread.snapshot"});for(const t of s.frames)this.sendFrame(r,e,t)}}close(){if(this.closed)return;this.closed=!0,this.releaseCurrentGeneration(!0);for(const e of this.releaseWorkspaceSubscriptions)e();const e=new Error("Companion Relay Wire 1.1 bridge closed");for(const t of this.workspaces.values())t.closeRemoteInteractions?.(e)}enqueue(e,t){this.enqueueTask(e,async()=>{await e.transport.send(t),this.generation===e&&this.markMessageAdvertised(e,t)})}enqueueTask(e,t){e.queue=e.queue.then(async()=>{this.generation===e&&await t()}).catch(()=>{this.failGeneration(e)})}failGeneration(e){if(this.generation===e){this.releaseCurrentGeneration();try{e.transport.close()}catch{}}}async handleMessage(e,t,r=!1){if(this.generation!==t)return;const s=R.safeParse(e);if(!s.success)return;const i=s.data;if(i.type===o.runtimeEncrypted){if("request"!==i.payloadKind||void 0===i.requestId)return;const e=this.ciphers.get(i.workspaceId);if(void 0===e)return;try{const r=g.parse(await e.decrypt(i));if(r.workspaceId!==i.workspaceId||r.requestId!==i.requestId)return;await this.handleMessage(r,t,!0)}catch{this.sendRuntimeResponse(t,{type:o.runtimeResponse,requestId:i.requestId,workspaceId:i.workspaceId,error:{code:n.encryptedPayloadInvalid,message:"Encrypted Relay payload could not be authenticated or parsed",retryable:d(n.encryptedPayloadInvalid)}})}return}if(i.type===o.runtimeAck){const e=this.workspaces.get(i.workspaceId);if(void 0===e)return;const r=this.acknowledged.get(i.workspaceId)??-1;return void("advance"===c({acknowledgedThrough:r,advertisedThrough:t.advertisedThrough.get(i.workspaceId)??-1,incomingThrough:i.throughRelaySequence})&&(this.acknowledged.set(i.workspaceId,i.throughRelaySequence),e.acknowledgeRelayFramesV11(i.throughRelaySequence)))}if(i.type!==o.runtimeRequest)return;if(!r&&this.ciphers.has(i.workspaceId))return void this.sendRuntimeResponse(t,{type:o.runtimeResponse,requestId:i.requestId,workspaceId:i.workspaceId,error:k});const a=this.workspaces.get(i.workspaceId);if(void 0===a)return void this.sendRuntimeResponse(t,{type:o.runtimeResponse,requestId:i.requestId,workspaceId:i.workspaceId,error:{code:n.workspaceNotFound,message:"The requested workspace is not paired with this Companion",retryable:d(n.workspaceNotFound)}});const p=await this.resolveRuntimeRequest(i,a,t);this.generation===t&&this.sendRuntimeResponse(t,p)}resolveRuntimeRequest(e,t,r){const s=`${e.workspaceId}:${e.requestId}`,a=this.inFlightRequestCache.get(s),c=this.settledRequestCache.get(s),h=a??c;if(void 0!==h){const t=I(e);return h.fingerprint===t?(void 0!==c&&(this.settledRequestCache.delete(s),this.settledRequestCache.set(s,c)),h.response):Promise.resolve({type:o.runtimeResponse,requestId:e.requestId,workspaceId:e.workspaceId,error:{code:n.requestIdConflict,message:"Relay requestId was reused with different method or params",retryable:d(n.requestIdConflict)}})}if("mutation"!==p("1.1",e.method))return this.executeRuntimeRequest(e,t,r);const u={fingerprint:I(e),response:this.executeRuntimeRequest(e,t,r),generation:e.method===i.interactionCandidate?r:void 0};return this.inFlightRequestCache.set(s,u),u.response.then(()=>this.settleRequest(s,u),()=>this.settleRequest(s,u)).catch(()=>{}),u.response}async executeRuntimeRequest(e,t,r){try{if("local-only"===p("1.1",e.method))throw new LocalOnlyRelayRequestV11Error(e.method);let n;try{n=s.parse(h("1.1",e.method,e.params))}catch{throw new InvalidRelayRequestParamsV11Error}const i=g.parse({...e,params:n}),a={signal:r.controller.signal,responderPolicy:r.responderPolicy,responderContext:r.responderContext,workspaceId:e.workspaceId,requestId:e.requestId},c=await t.handleRemoteRequestV11(i,a);return{type:o.runtimeResponse,requestId:e.requestId,workspaceId:e.workspaceId,result:C(i,c)}}catch(t){return{type:o.runtimeResponse,requestId:e.requestId,workspaceId:e.workspaceId,error:E(t)}}}settleRequest(e,t){if(this.inFlightRequestCache.get(e)===t)for(this.inFlightRequestCache.delete(e),this.settledRequestCache.set(e,t);this.settledRequestCache.size>this.maxRequestCacheEntries;){const e=this.settledRequestCache.keys().next().value;if(void 0===e)return;this.settledRequestCache.delete(e)}}sendFrame(e,r,n){const i=w(r,n),a=this.ciphers.get(r);void 0!==a?this.enqueueTask(e,async()=>{const c=await a.encrypt(s.parse(i));if(this.generation!==e)return;const d=i.type===o.runtimeEvent?"event":"interaction";await e.transport.send({type:o.runtimeEncrypted,workspaceId:r,envelopeId:y.parse(t()),payloadKind:d,relaySequence:n.relaySequence,algorithm:a.algorithm,nonce:c.nonce,ciphertext:c.ciphertext}),this.generation===e&&this.markAdvertised(e,r,n.relaySequence)}):this.enqueue(e,i)}sendRuntimeResponse(e,r){const n=this.ciphers.get(r.workspaceId);void 0!==n?this.enqueueTask(e,async()=>{const i=await n.encrypt(s.parse(r));this.generation===e&&await e.transport.send({type:o.runtimeEncrypted,workspaceId:r.workspaceId,envelopeId:y.parse(t()),payloadKind:"response",requestId:r.requestId,algorithm:n.algorithm,nonce:i.nonce,ciphertext:i.ciphertext})}):this.enqueue(e,r)}releaseCurrentGeneration(e=!1){const t=this.generation;if(this.generation=void 0,t?.controller.abort(new Error("Relay transport generation ended")),void 0!==t)for(const[e,r]of this.inFlightRequestCache)r.generation===t&&this.inFlightRequestCache.delete(e);for(const e of this.releaseTransportSubscriptions)e();this.releaseTransportSubscriptions=[],e&&t?.transport.close()}markMessageAdvertised(e,t){t.type!==o.runtimeGap?t.type!==o.runtimeEvent&&t.type!==o.interactionRequest&&t.type!==o.interactionResolved&&t.type!==o.interactionCancelled||this.markAdvertised(e,t.workspaceId,t.relaySequence):this.markAdvertised(e,t.workspaceId,t.throughRelaySequence)}markAdvertised(e,t,r){e.advertisedThrough.set(t,Math.max(e.advertisedThrough.get(t)??-1,r))}}export function createWebSocketRelayTransportV11(e){return{send(t){e.send(JSON.stringify(t))},onMessage(t){const r=e=>{if("string"==typeof e.data)try{t(JSON.parse(e.data))}catch{}};return e.addEventListener("message",r),()=>e.removeEventListener("message",r)},onClose:t=>(e.addEventListener("close",t),()=>e.removeEventListener("close",t)),close(){e.close()}}}export class OutboundCompanionRelayV11{bridge;connectTransport;minReconnectMs;maxReconnectMs;reconnectMs;timer;transport;stopped=!0;connecting=!1;constructor(e){this.bridge=e.bridge,this.connectTransport=e.connectTransport,this.minReconnectMs=e.minReconnectMs??500,this.maxReconnectMs=e.maxReconnectMs??3e4,this.reconnectMs=this.minReconnectMs}start(){this.stopped&&(this.stopped=!1,this.connect().catch(()=>{}))}stop(){this.stopped=!0,void 0!==this.timer&&(clearTimeout(this.timer),this.timer=void 0),this.transport?.close(),this.transport=void 0,this.bridge.close()}async connect(){if(!this.stopped&&!this.connecting){this.connecting=!0;try{const e=await this.connectTransport();if(this.stopped)return void e.transport.close();this.transport=e.transport,this.reconnectMs=this.minReconnectMs,e.transport.onClose(()=>{this.transport===e.transport&&(this.transport=void 0,this.scheduleReconnect())}),this.bridge.connect(e.transport,{responderPolicy:e.responderPolicy,responderContext:e.responderContext})}catch{this.scheduleReconnect()}finally{this.connecting=!1}}}scheduleReconnect(){if(this.stopped||void 0!==this.timer)return;const e=this.reconnectMs;this.reconnectMs=Math.min(2*this.reconnectMs,this.maxReconnectMs),this.timer=setTimeout(()=>{this.timer=void 0,this.connect().catch(()=>{})},e)}}
|
package/dist/relay-bridge.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash as e,randomUUID as t}from"node:crypto";import{RollRpcError as s}from"@roll-agent/client-node";import{jsonValueSchema as r}from"@roll-agent/protocol";import{CompanionWorkspace as n,InvalidRelayRequestParamsError as o,LocalApprovalDeniedError as i,LocalConfirmationRequiredError as a}from"./companion-workspace.js";import{COMPANION_RELAY_PROTOCOL_VERSION as c,RELAY_ERROR_CODES as d,canonicalizeRelayJson as p,classifyRelayAck as h,getRelayErrorRetryability as u,isRelayMutationRequestMethod as l,relayEnvelopeIdSchema as m,relayMessageSchema as g,relayRuntimeRequestSchema as q}from"@roll-agent/relay-protocol";const y=1e4,R={code:d.encryptionRequired,message:"Encrypted Relay request required for this workspace",retryable:u(d.encryptionRequired)};function v(t){const s={workspaceId:t.workspaceId,method:t.method,params:t.params};return e("sha256").update(p(s)).digest("hex")}function w(e){if(e instanceof o)return{code:d.invalidParams,message:"Invalid Relay request params",retryable:u(d.invalidParams)};if(e instanceof s)return{code:e.data?.rollCode??`JSON_RPC_${String(e.code)}`,message:e.message,retryable:e.data?.retryable??!1};if(e instanceof i||e instanceof a){const t=e instanceof i?d.localApprovalDenied:d.localConfirmationRequired;return{code:t,message:e.message,retryable:u(t)}}return{code:d.companionError,message:"Companion request failed",retryable:u(d.companionError)}}export class CompanionRelayBridge{deviceId;pairingToken;workspaces;ciphers;maxRequestCacheEntries;acknowledged=new Map;inFlightRequestCache=new Map;settledRequestCache=new Map;releaseWorkspaceSubscriptions;transportGeneration;releaseTransportSubscriptions=[];constructor(e){if(this.deviceId=e.deviceId,this.pairingToken=e.pairingToken,this.workspaces=e.workspaces,this.ciphers=e.ciphers??new Map,this.maxRequestCacheEntries=e.maxRequestCacheEntries??y,!Number.isInteger(this.maxRequestCacheEntries)||this.maxRequestCacheEntries<1)throw new Error("maxRequestCacheEntries must be a positive integer");this.releaseWorkspaceSubscriptions=[...this.workspaces.entries()].map(([e,t])=>t.onBufferedEvent(t=>{void 0!==this.transportGeneration&&this.sendRuntimeEvent(e,t.relaySequence,t.event)}))}connect(e){this.releaseCurrentTransport(!0);const t={transport:e,advertisedThrough:new Map,queue:Promise.resolve()};this.transportGeneration=t,this.releaseTransportSubscriptions=[e.onMessage(e=>{this.handleMessage(e,t).catch(()=>{})}),e.onClose(()=>{this.transportGeneration===t&&this.releaseCurrentTransport()})],this.enqueue({type:"device.connect",protocolVersion:c,deviceId:this.deviceId,pairingToken:this.pairingToken});for(const[e,t]of this.workspaces){const s=t.replay(this.acknowledged.get(e)??-1);void 0!==s.gap&&this.enqueue({type:"runtime.gap",workspaceId:e,fromRelaySequence:s.gap.fromRelaySequence,throughRelaySequence:s.gap.throughRelaySequence,recovery:"thread.snapshot"});for(const t of s.events)this.sendRuntimeEvent(e,t.relaySequence,t.event)}}close(){this.releaseCurrentTransport(!0);for(const e of this.releaseWorkspaceSubscriptions)e()}enqueue(e){const t=this.transportGeneration;void 0!==t&&this.enqueueTask(t,async()=>{await t.transport.send(e),this.transportGeneration===t&&("runtime.event"===e.type?this.markAdvertised(t,e.workspaceId,e.relaySequence):"runtime.gap"===e.type&&this.markAdvertised(t,e.workspaceId,e.throughRelaySequence))})}enqueueTask(e,t){e.queue=e.queue.then(async()=>{this.transportGeneration===e&&await t()}).catch(()=>{this.failTransportGeneration(e)})}failTransportGeneration(e){if(this.transportGeneration===e){this.releaseCurrentTransport();try{e.transport.close()}catch{}}}async handleMessage(e,t,s=!1){if(this.transportGeneration!==t)return;const r=g.safeParse(e);if(!r.success)return;const n=r.data;if("runtime.encrypted"===n.type){if("request"!==n.payloadKind||void 0===n.requestId)return;const e=this.ciphers.get(n.workspaceId);if(void 0===e)return;try{const s=q.parse(await e.decrypt(n));if(s.workspaceId!==n.workspaceId||s.requestId!==n.requestId)return;await this.handleMessage(s,t,!0)}catch{this.sendRuntimeResponse({type:"runtime.response",requestId:n.requestId,workspaceId:n.workspaceId,error:{code:d.encryptedPayloadInvalid,message:"Encrypted Relay payload could not be authenticated or parsed",retryable:u(d.encryptedPayloadInvalid)}})}return}if("runtime.ack"===n.type){const e=this.workspaces.get(n.workspaceId);if(void 0===e)return;const s=this.acknowledged.get(n.workspaceId)??-1;return void("advance"===h({acknowledgedThrough:s,advertisedThrough:t.advertisedThrough.get(n.workspaceId)??-1,incomingThrough:n.throughRelaySequence})&&(this.acknowledged.set(n.workspaceId,n.throughRelaySequence),e.acknowledge(n.throughRelaySequence)))}if("runtime.request"!==n.type)return;if(!s&&this.ciphers.has(n.workspaceId))return void this.sendRuntimeResponse({type:"runtime.response",requestId:n.requestId,workspaceId:n.workspaceId,error:R});const o=this.workspaces.get(n.workspaceId);if(void 0===o)return void this.sendRuntimeResponse({type:"runtime.response",requestId:n.requestId,workspaceId:n.workspaceId,error:{code:d.workspaceNotFound,message:"The requested workspace is not paired with this Companion",retryable:u(d.workspaceNotFound)}});const i=await this.resolveRuntimeRequest(n,o);this.sendRuntimeResponse(i)}resolveRuntimeRequest(e,t){const s=`${e.workspaceId}:${e.requestId}`,r=this.inFlightRequestCache.get(s),n=this.settledRequestCache.get(s),o=r??n;if(void 0!==o){const t=v(e);return o.fingerprint===t?(void 0!==n&&(this.settledRequestCache.delete(s),this.settledRequestCache.set(s,n)),o.response):Promise.resolve({type:"runtime.response",requestId:e.requestId,workspaceId:e.workspaceId,error:{code:d.requestIdConflict,message:"Relay requestId was reused with different method or params",retryable:u(d.requestIdConflict)}})}if(!l(e.method))return this.executeRuntimeRequest(e,t);const i={fingerprint:v(e),response:this.executeRuntimeRequest(e,t)};return this.inFlightRequestCache.set(s,i),i.response.then(()=>this.settleRequest(s,i),()=>this.settleRequest(s,i)).catch(()=>{}),i.response}async executeRuntimeRequest(e,t){try{const s=await t.handleRemoteRequest(e);return{type:"runtime.response",requestId:e.requestId,workspaceId:e.workspaceId,result:r.parse(s)}}catch(t){return{type:"runtime.response",requestId:e.requestId,workspaceId:e.workspaceId,error:w(t)}}}settleRequest(e,t){if(this.inFlightRequestCache.get(e)===t)for(this.inFlightRequestCache.delete(e),this.settledRequestCache.set(e,t);this.settledRequestCache.size>this.maxRequestCacheEntries;){const e=this.settledRequestCache.keys().next().value;if(void 0===e)return;this.settledRequestCache.delete(e)}}sendRuntimeEvent(e,s,n){const o=this.ciphers.get(e);if(void 0===o)return void this.enqueue({type:"runtime.event",workspaceId:e,relaySequence:s,event:n});const i=this.transportGeneration;void 0!==i&&this.enqueueTask(i,async()=>{const a=await o.encrypt(r.parse(n));this.transportGeneration===i&&(await i.transport.send({type:"runtime.encrypted",workspaceId:e,envelopeId:m.parse(t()),payloadKind:"event",relaySequence:s,algorithm:o.algorithm,nonce:a.nonce,ciphertext:a.ciphertext}),this.transportGeneration===i&&this.markAdvertised(i,e,s))})}sendRuntimeResponse(e){const s=this.ciphers.get(e.workspaceId);if(void 0===s)return void this.enqueue(e);const n=this.transportGeneration;void 0!==n&&this.enqueueTask(n,async()=>{const o=await s.encrypt(r.parse(e));this.transportGeneration===n&&await n.transport.send({type:"runtime.encrypted",workspaceId:e.workspaceId,envelopeId:m.parse(t()),payloadKind:"response",requestId:e.requestId,algorithm:s.algorithm,nonce:o.nonce,ciphertext:o.ciphertext})})}releaseCurrentTransport(e=!1){const t=this.transportGeneration;this.transportGeneration=void 0;for(const e of this.releaseTransportSubscriptions)e();this.releaseTransportSubscriptions=[],e&&t?.transport.close()}markAdvertised(e,t,s){e.advertisedThrough.set(t,Math.max(e.advertisedThrough.get(t)??-1,s))}}export function createWebSocketRelayTransport(e){return{send(t){e.send(JSON.stringify(t))},onMessage(t){const s=e=>{if("string"==typeof e.data)try{t(JSON.parse(e.data))}catch{}};return e.addEventListener("message",s),()=>e.removeEventListener("message",s)},onClose:t=>(e.addEventListener("close",t),()=>e.removeEventListener("close",t)),close(){e.close()}}}export class OutboundCompanionRelay{bridge;connectTransport;minReconnectMs;maxReconnectMs;reconnectMs;timer;transport;stopped=!0;connecting=!1;constructor(e){this.bridge=e.bridge,this.connectTransport=e.connectTransport,this.minReconnectMs=e.minReconnectMs??500,this.maxReconnectMs=e.maxReconnectMs??3e4,this.reconnectMs=this.minReconnectMs}start(){this.stopped&&(this.stopped=!1,this.connect().catch(()=>{}))}stop(){this.stopped=!0,void 0!==this.timer&&(clearTimeout(this.timer),this.timer=void 0),this.transport?.close(),this.transport=void 0,this.bridge.close()}async connect(){if(!this.stopped&&!this.connecting){this.connecting=!0;try{const e=await this.connectTransport();if(this.stopped)return void e.close();this.transport=e,this.reconnectMs=this.minReconnectMs,e.onClose(()=>{this.transport===e&&(this.transport=void 0,this.scheduleReconnect())}),this.bridge.connect(e)}catch{this.scheduleReconnect()}finally{this.connecting=!1}}}scheduleReconnect(){if(this.stopped||void 0!==this.timer)return;const e=this.reconnectMs;this.reconnectMs=Math.min(2*this.reconnectMs,this.maxReconnectMs),this.timer=setTimeout(()=>{this.timer=void 0,this.connect().catch(()=>{})},e)}}export function relayEventMessage(e,t,s){return{type:"runtime.event",workspaceId:e,relaySequence:t,event:s}}
|
|
1
|
+
import{createHash as e,randomUUID as t}from"node:crypto";import{RollRpcError as s}from"@roll-agent/client-node";import{jsonValueSchema as r,projectThreadSnapshotForVersion as n,runtimeEventEnvelopeV11Schema as o}from"@roll-agent/protocol";import{CompanionWorkspace as i,InvalidRelayRequestParamsError as a,LocalApprovalDeniedError as c,LocalConfirmationRequiredError as d}from"./companion-workspace.js";import{COMPANION_RELAY_PROTOCOL_VERSION as p,RELAY_ERROR_CODES as h,RELAY_REQUEST_METHODS as u,canonicalizeRelayJson as l,classifyRelayAck as m,getRelayErrorRetryability as q,isRelayMutationRequestMethod as g,relayEnvelopeIdSchema as y,relayMessageSchema as v,relayRequestMethodSchemas as R,relayRuntimeRequestSchema as f}from"@roll-agent/relay-protocol";const I=1e4,w={code:h.encryptionRequired,message:"Encrypted Relay request required for this workspace",retryable:q(h.encryptionRequired)};function k(e,t){const s=R[e.method].result,o=s.safeParse(t);if(o.success)return r.parse(o.data);if(e.method===u.threadOpen||e.method===u.threadSnapshot){const e=n("1.1",t);return r.parse(s.parse(e))}throw o.error}function C(e){return"1.0"===e.protocolVersion?o.parse(e):o.parse({protocolVersion:"1.1",runtimeInstanceId:e.runtimeInstanceId,sequence:e.sequence,timestamp:e.timestamp,threadId:e.threadId,...void 0===e.turnId?{}:{turnId:e.turnId},event:e.event})}function T(t){const s={workspaceId:t.workspaceId,method:t.method,params:t.params};return e("sha256").update(l(s)).digest("hex")}function M(e){if(e instanceof a)return{code:h.invalidParams,message:"Invalid Relay request params",retryable:q(h.invalidParams)};if(e instanceof s)return{code:e.data?.rollCode??`JSON_RPC_${String(e.code)}`,message:"Runtime request failed",retryable:e.data?.retryable??!1};if(e instanceof c||e instanceof d){const t=e instanceof c?h.localApprovalDenied:h.localConfirmationRequired;return{code:t,message:t===h.localApprovalDenied?"Local approval denied":"Local confirmation required",retryable:q(t)}}return{code:h.companionError,message:"Companion request failed",retryable:q(h.companionError)}}export class CompanionRelayBridge{deviceId;pairingToken;workspaces;ciphers;maxRequestCacheEntries;acknowledged=new Map;inFlightRequestCache=new Map;settledRequestCache=new Map;releaseWorkspaceSubscriptions;transportGeneration;releaseTransportSubscriptions=[];constructor(e){if(this.deviceId=e.deviceId,this.pairingToken=e.pairingToken,this.workspaces=e.workspaces,this.ciphers=e.ciphers??new Map,this.maxRequestCacheEntries=e.maxRequestCacheEntries??I,!Number.isInteger(this.maxRequestCacheEntries)||this.maxRequestCacheEntries<1)throw new Error("maxRequestCacheEntries must be a positive integer");this.releaseWorkspaceSubscriptions=[...this.workspaces.entries()].map(([e,t])=>t.onBufferedEvent(t=>{void 0!==this.transportGeneration&&this.sendRuntimeEvent(e,t.relaySequence,t.event)}))}connect(e){this.releaseCurrentTransport(!0);const t={transport:e,advertisedThrough:new Map,queue:Promise.resolve()};this.transportGeneration=t,this.releaseTransportSubscriptions=[e.onMessage(e=>{this.handleMessage(e,t).catch(()=>{})}),e.onClose(()=>{this.transportGeneration===t&&this.releaseCurrentTransport()})],this.enqueue({type:"device.connect",protocolVersion:p,deviceId:this.deviceId,pairingToken:this.pairingToken});for(const[e,t]of this.workspaces){const s=t.replay(this.acknowledged.get(e)??-1);void 0!==s.gap&&this.enqueue({type:"runtime.gap",workspaceId:e,fromRelaySequence:s.gap.fromRelaySequence,throughRelaySequence:s.gap.throughRelaySequence,recovery:"thread.snapshot"});for(const t of s.events)this.sendRuntimeEvent(e,t.relaySequence,t.event)}}close(){this.releaseCurrentTransport(!0);for(const e of this.releaseWorkspaceSubscriptions)e()}enqueue(e){const t=this.transportGeneration;void 0!==t&&this.enqueueTask(t,async()=>{await t.transport.send(e),this.transportGeneration===t&&("runtime.event"===e.type?this.markAdvertised(t,e.workspaceId,e.relaySequence):"runtime.gap"===e.type&&this.markAdvertised(t,e.workspaceId,e.throughRelaySequence))})}enqueueTask(e,t){e.queue=e.queue.then(async()=>{this.transportGeneration===e&&await t()}).catch(()=>{this.failTransportGeneration(e)})}failTransportGeneration(e){if(this.transportGeneration===e){this.releaseCurrentTransport();try{e.transport.close()}catch{}}}async handleMessage(e,t,s=!1){if(this.transportGeneration!==t)return;const r=v.safeParse(e);if(!r.success)return;const n=r.data;if("runtime.encrypted"===n.type){if("request"!==n.payloadKind||void 0===n.requestId)return;const e=this.ciphers.get(n.workspaceId);if(void 0===e)return;try{const s=f.parse(await e.decrypt(n));if(s.workspaceId!==n.workspaceId||s.requestId!==n.requestId)return;await this.handleMessage(s,t,!0)}catch{this.sendRuntimeResponse({type:"runtime.response",requestId:n.requestId,workspaceId:n.workspaceId,error:{code:h.encryptedPayloadInvalid,message:"Encrypted Relay payload could not be authenticated or parsed",retryable:q(h.encryptedPayloadInvalid)}})}return}if("runtime.ack"===n.type){const e=this.workspaces.get(n.workspaceId);if(void 0===e)return;const s=this.acknowledged.get(n.workspaceId)??-1;return void("advance"===m({acknowledgedThrough:s,advertisedThrough:t.advertisedThrough.get(n.workspaceId)??-1,incomingThrough:n.throughRelaySequence})&&(this.acknowledged.set(n.workspaceId,n.throughRelaySequence),e.acknowledge(n.throughRelaySequence)))}if("runtime.request"!==n.type)return;if(!s&&this.ciphers.has(n.workspaceId))return void this.sendRuntimeResponse({type:"runtime.response",requestId:n.requestId,workspaceId:n.workspaceId,error:w});const o=this.workspaces.get(n.workspaceId);if(void 0===o)return void this.sendRuntimeResponse({type:"runtime.response",requestId:n.requestId,workspaceId:n.workspaceId,error:{code:h.workspaceNotFound,message:"The requested workspace is not paired with this Companion",retryable:q(h.workspaceNotFound)}});const i=await this.resolveRuntimeRequest(n,o);this.sendRuntimeResponse(i)}resolveRuntimeRequest(e,t){const s=`${e.workspaceId}:${e.requestId}`,r=this.inFlightRequestCache.get(s),n=this.settledRequestCache.get(s),o=r??n;if(void 0!==o){const t=T(e);return o.fingerprint===t?(void 0!==n&&(this.settledRequestCache.delete(s),this.settledRequestCache.set(s,n)),o.response):Promise.resolve({type:"runtime.response",requestId:e.requestId,workspaceId:e.workspaceId,error:{code:h.requestIdConflict,message:"Relay requestId was reused with different method or params",retryable:q(h.requestIdConflict)}})}if(!g(e.method))return this.executeRuntimeRequest(e,t);const i={fingerprint:T(e),response:this.executeRuntimeRequest(e,t)};return this.inFlightRequestCache.set(s,i),i.response.then(()=>this.settleRequest(s,i),()=>this.settleRequest(s,i)).catch(()=>{}),i.response}async executeRuntimeRequest(e,t){try{const s=await t.handleRemoteRequest(e);return{type:"runtime.response",requestId:e.requestId,workspaceId:e.workspaceId,result:k(e,s)}}catch(t){return{type:"runtime.response",requestId:e.requestId,workspaceId:e.workspaceId,error:M(t)}}}settleRequest(e,t){if(this.inFlightRequestCache.get(e)===t)for(this.inFlightRequestCache.delete(e),this.settledRequestCache.set(e,t);this.settledRequestCache.size>this.maxRequestCacheEntries;){const e=this.settledRequestCache.keys().next().value;if(void 0===e)return;this.settledRequestCache.delete(e)}}sendRuntimeEvent(e,s,n){const o=C(n),i=this.ciphers.get(e);if(void 0===i)return void this.enqueue({type:"runtime.event",workspaceId:e,relaySequence:s,event:o});const a=this.transportGeneration;void 0!==a&&this.enqueueTask(a,async()=>{const n=await i.encrypt(r.parse(o));this.transportGeneration===a&&(await a.transport.send({type:"runtime.encrypted",workspaceId:e,envelopeId:y.parse(t()),payloadKind:"event",relaySequence:s,algorithm:i.algorithm,nonce:n.nonce,ciphertext:n.ciphertext}),this.transportGeneration===a&&this.markAdvertised(a,e,s))})}sendRuntimeResponse(e){const s=this.ciphers.get(e.workspaceId);if(void 0===s)return void this.enqueue(e);const n=this.transportGeneration;void 0!==n&&this.enqueueTask(n,async()=>{const o=await s.encrypt(r.parse(e));this.transportGeneration===n&&await n.transport.send({type:"runtime.encrypted",workspaceId:e.workspaceId,envelopeId:y.parse(t()),payloadKind:"response",requestId:e.requestId,algorithm:s.algorithm,nonce:o.nonce,ciphertext:o.ciphertext})})}releaseCurrentTransport(e=!1){const t=this.transportGeneration;this.transportGeneration=void 0;for(const e of this.releaseTransportSubscriptions)e();this.releaseTransportSubscriptions=[],e&&t?.transport.close()}markAdvertised(e,t,s){e.advertisedThrough.set(t,Math.max(e.advertisedThrough.get(t)??-1,s))}}export function createWebSocketRelayTransport(e){return{send(t){e.send(JSON.stringify(t))},onMessage(t){const s=e=>{if("string"==typeof e.data)try{t(JSON.parse(e.data))}catch{}};return e.addEventListener("message",s),()=>e.removeEventListener("message",s)},onClose:t=>(e.addEventListener("close",t),()=>e.removeEventListener("close",t)),close(){e.close()}}}export class OutboundCompanionRelay{bridge;connectTransport;minReconnectMs;maxReconnectMs;reconnectMs;timer;transport;stopped=!0;connecting=!1;constructor(e){this.bridge=e.bridge,this.connectTransport=e.connectTransport,this.minReconnectMs=e.minReconnectMs??500,this.maxReconnectMs=e.maxReconnectMs??3e4,this.reconnectMs=this.minReconnectMs}start(){this.stopped&&(this.stopped=!1,this.connect().catch(()=>{}))}stop(){this.stopped=!0,void 0!==this.timer&&(clearTimeout(this.timer),this.timer=void 0),this.transport?.close(),this.transport=void 0,this.bridge.close()}async connect(){if(!this.stopped&&!this.connecting){this.connecting=!0;try{const e=await this.connectTransport();if(this.stopped)return void e.close();this.transport=e,this.reconnectMs=this.minReconnectMs,e.onClose(()=>{this.transport===e&&(this.transport=void 0,this.scheduleReconnect())}),this.bridge.connect(e)}catch{this.scheduleReconnect()}finally{this.connecting=!1}}}scheduleReconnect(){if(this.stopped||void 0!==this.timer)return;const e=this.reconnectMs;this.reconnectMs=Math.min(2*this.reconnectMs,this.maxReconnectMs),this.timer=setTimeout(()=>{this.timer=void 0,this.connect().catch(()=>{})},e)}}export function relayEventMessage(e,t,s){return{type:"runtime.event",workspaceId:e,relaySequence:t,event:C(s)}}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type RuntimeEventEnvelope } from "@roll-agent/protocol";
|
|
2
|
+
import { type RelayMessageV11, type RelayRuntimeEventEnvelopeV11, type WorkspaceId } from "@roll-agent/relay-protocol";
|
|
3
|
+
import type { CompanionInteractionFrameDraftV11 } from "./interaction-broker.ts";
|
|
4
|
+
export declare const DEFAULT_COMPANION_RELAY_FRAME_MAX_EVENTS = 10000;
|
|
5
|
+
export declare const DEFAULT_COMPANION_RELAY_FRAME_MAX_BYTES: number;
|
|
6
|
+
export interface BufferedRelayRuntimeEventV11 {
|
|
7
|
+
readonly type: "runtime.event";
|
|
8
|
+
readonly relaySequence: number;
|
|
9
|
+
readonly event: RelayRuntimeEventEnvelopeV11;
|
|
10
|
+
}
|
|
11
|
+
export type BufferedRelayInteractionFrameV11 = CompanionInteractionFrameDraftV11 & {
|
|
12
|
+
readonly relaySequence: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A replayable Wire 1.1 frame with workspace routing deliberately omitted.
|
|
16
|
+
* The owning bridge supplies the workspace only when materializing the frame.
|
|
17
|
+
*/
|
|
18
|
+
export type CompanionRelayFrameEntryV11 = BufferedRelayRuntimeEventV11 | BufferedRelayInteractionFrameV11;
|
|
19
|
+
export interface RelayFrameBufferGapV11 {
|
|
20
|
+
readonly fromRelaySequence: number;
|
|
21
|
+
readonly throughRelaySequence: number;
|
|
22
|
+
}
|
|
23
|
+
export interface CompanionRelayFrameReplayV11 {
|
|
24
|
+
readonly frames: readonly CompanionRelayFrameEntryV11[];
|
|
25
|
+
readonly gap: RelayFrameBufferGapV11 | undefined;
|
|
26
|
+
}
|
|
27
|
+
export interface CompanionRelayFrameBufferOptions {
|
|
28
|
+
readonly maxEvents?: number;
|
|
29
|
+
readonly maxBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Materializes and strictly validates an outbound replayable Wire 1.1 frame. */
|
|
32
|
+
export declare function materializeRelayFrameV11(workspaceId: WorkspaceId, entry: CompanionRelayFrameEntryV11): RelayMessageV11;
|
|
33
|
+
/**
|
|
34
|
+
* Per-workspace in-memory replay buffer for Wire 1.1 runtime and interaction frames.
|
|
35
|
+
* It is intentionally independent from the frozen Wire 1.0 `CompanionEventBuffer`.
|
|
36
|
+
*/
|
|
37
|
+
export declare class CompanionRelayFrameBuffer {
|
|
38
|
+
private readonly maxEvents;
|
|
39
|
+
private readonly maxBytes;
|
|
40
|
+
private readonly entries;
|
|
41
|
+
private nextSequence;
|
|
42
|
+
private retainedBytes;
|
|
43
|
+
private droppedThroughSequence;
|
|
44
|
+
constructor(options?: CompanionRelayFrameBufferOptions);
|
|
45
|
+
appendRuntimeEvent(event: RuntimeEventEnvelope): CompanionRelayFrameEntryV11 | undefined;
|
|
46
|
+
appendInteraction(frame: CompanionInteractionFrameDraftV11): CompanionRelayFrameEntryV11;
|
|
47
|
+
replay(afterRelaySequence?: number): CompanionRelayFrameReplayV11;
|
|
48
|
+
acknowledge(throughRelaySequence: number): void;
|
|
49
|
+
get size(): number;
|
|
50
|
+
get bytes(): number;
|
|
51
|
+
get highestRelaySequence(): number;
|
|
52
|
+
private append;
|
|
53
|
+
private enforceLimits;
|
|
54
|
+
private dropOldest;
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{RUNTIME_V13_DEFAULT_REPLAY_BUFFER_BYTES as e}from"@roll-agent/protocol";import{projectRuntimeEventEnvelopeForRelayV11 as t,relayInteractionCancelledSchemaV11 as r,relayInteractionRequestSchemaV11 as n,relayInteractionResolvedSchemaV11 as s,relayRuntimeEventSchemaV11 as i,workspaceIdSchema as a}from"@roll-agent/relay-protocol";export const DEFAULT_COMPANION_RELAY_FRAME_MAX_EVENTS=1e4;export const DEFAULT_COMPANION_RELAY_FRAME_MAX_BYTES=e;const o=a.parse("00000000-0000-4000-8000-000000000000");function u(e){if("workspaceId"in e||"relaySequence"in e)throw new Error("Buffered interaction drafts must not contain workspace routing fields");const t={...e,workspaceId:o,relaySequence:0};if("interaction.request"===e.type){const{workspaceId:e,relaySequence:r,...s}=n.parse(t);return s}if("interaction.resolved"===e.type){const{workspaceId:e,relaySequence:r,...n}=s.parse(t);return n}const{workspaceId:i,relaySequence:a,...u}=r.parse(t);return u}export function materializeRelayFrameV11(e,t){return"runtime.event"===t.type?i.parse({...t,workspaceId:e}):"interaction.request"===t.type?n.parse({...t,workspaceId:e}):"interaction.resolved"===t.type?s.parse({...t,workspaceId:e}):r.parse({...t,workspaceId:e})}export class CompanionRelayFrameBuffer{maxEvents;maxBytes;entries=[];nextSequence=0;retainedBytes=0;droppedThroughSequence=-1;constructor(e={}){if(this.maxEvents=e.maxEvents??1e4,this.maxBytes=e.maxBytes??DEFAULT_COMPANION_RELAY_FRAME_MAX_BYTES,!Number.isInteger(this.maxEvents)||this.maxEvents<1)throw new Error("maxEvents must be a positive integer");if(!Number.isInteger(this.maxBytes)||this.maxBytes<1)throw new Error("maxBytes must be a positive integer")}appendRuntimeEvent(e){const r=t(e);if(void 0!==r)return this.append({type:"runtime.event",relaySequence:this.nextSequence,event:r})}appendInteraction(e){const t=u(e);return this.append({...t,relaySequence:this.nextSequence})}replay(e=-1){if(!Number.isInteger(e)||e<-1)throw new Error("afterRelaySequence must be an integer greater than or equal to -1");const t=e<this.droppedThroughSequence?{fromRelaySequence:e+1,throughRelaySequence:this.droppedThroughSequence}:void 0;return{frames:this.entries.filter(t=>t.frame.relaySequence>e).map(e=>e.frame),gap:t}}acknowledge(e){if(!Number.isInteger(e)||e<-1)throw new Error("throughRelaySequence must be an integer greater than or equal to -1");if(!(e>this.highestRelaySequence))for(;void 0!==this.entries[0]&&this.entries[0].frame.relaySequence<=e;)this.dropOldest()}get size(){return this.entries.length}get bytes(){return this.retainedBytes}get highestRelaySequence(){return this.nextSequence-1}append(e){const t={frame:e,bytes:Buffer.byteLength(JSON.stringify(e),"utf8")};return this.nextSequence+=1,this.entries.push(t),this.retainedBytes+=t.bytes,this.enforceLimits(),e}enforceLimits(){for(;this.entries.length>this.maxEvents||this.retainedBytes>this.maxBytes&&this.entries.length>0;)this.dropOldest()}dropOldest(){const e=this.entries.shift();void 0!==e&&(this.retainedBytes-=e.bytes,this.droppedThroughSequence=Math.max(this.droppedThroughSequence,e.frame.relaySequence))}}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type RelayInteractionCandidateParamsV11, type RelayMessageV11, type RelayRequestId, type RelayRuntimeRequestV11, type WorkspaceId } from "@roll-agent/relay-protocol";
|
|
2
|
+
import type { RelayTransportV11 } from "./relay-bridge-v11.ts";
|
|
3
|
+
export interface InMemoryRelayCandidateInputV11 {
|
|
4
|
+
readonly requestId: RelayRequestId;
|
|
5
|
+
readonly workspaceId: WorkspaceId;
|
|
6
|
+
readonly params: RelayInteractionCandidateParamsV11;
|
|
7
|
+
}
|
|
8
|
+
type MessageListener = (message: unknown) => void;
|
|
9
|
+
type CloseListener = () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic in-memory Wire 1.1 transport for Companion tests.
|
|
12
|
+
*
|
|
13
|
+
* This fake provides no authentication, device or controller election, reliable delivery,
|
|
14
|
+
* persistent outbox, or interaction WAL. It must never be used as a production identity or
|
|
15
|
+
* transport implementation.
|
|
16
|
+
*/
|
|
17
|
+
export declare class InMemoryRelayTransportV11 implements RelayTransportV11 {
|
|
18
|
+
private readonly outboundMessages;
|
|
19
|
+
private readonly messageListeners;
|
|
20
|
+
private readonly closeListeners;
|
|
21
|
+
private lateMessageListeners;
|
|
22
|
+
private disconnected;
|
|
23
|
+
get outbound(): readonly RelayMessageV11[];
|
|
24
|
+
get isDisconnected(): boolean;
|
|
25
|
+
send(message: RelayMessageV11): void;
|
|
26
|
+
onMessage(listener: MessageListener): () => void;
|
|
27
|
+
onClose(listener: CloseListener): () => void;
|
|
28
|
+
close(): void;
|
|
29
|
+
injectCandidate(input: InMemoryRelayCandidateInputV11): RelayRuntimeRequestV11;
|
|
30
|
+
injectDuplicateCandidate(input: InMemoryRelayCandidateInputV11): RelayRuntimeRequestV11;
|
|
31
|
+
injectAck(workspaceId: WorkspaceId, throughRelaySequence: number): void;
|
|
32
|
+
/** Delivers a candidate to the generation listeners captured immediately before disconnect. */
|
|
33
|
+
injectLateCandidate(input: InMemoryRelayCandidateInputV11): RelayRuntimeRequestV11;
|
|
34
|
+
disconnect(): void;
|
|
35
|
+
private emit;
|
|
36
|
+
private emitMessage;
|
|
37
|
+
}
|
|
38
|
+
export {};
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{RELAY_REQUEST_METHODS_V11 as e,relayAckSchemaV11 as s,relayMessageSchemaV11 as t,relayRuntimeRequestSchemaV11 as n}from"@roll-agent/relay-protocol";function i(s){return n.parse({type:"runtime.request",requestId:s.requestId,workspaceId:s.workspaceId,method:e.interactionCandidate,params:s.params})}export class InMemoryRelayTransportV11{outboundMessages=[];messageListeners=new Set;closeListeners=new Set;lateMessageListeners=[];disconnected=!1;get outbound(){return this.outboundMessages}get isDisconnected(){return this.disconnected}send(e){if(this.disconnected)throw new Error("Cannot send on a disconnected in-memory Relay transport");this.outboundMessages.push(t.parse(e))}onMessage(e){if(this.disconnected)throw new Error("Cannot subscribe to a disconnected in-memory Relay transport");return this.messageListeners.add(e),()=>{this.messageListeners.delete(e)}}onClose(e){if(this.disconnected)throw new Error("Cannot subscribe to a disconnected in-memory Relay transport");return this.closeListeners.add(e),()=>{this.closeListeners.delete(e)}}close(){this.disconnect()}injectCandidate(e){const s=i(e);return this.emit(s,this.messageListeners),s}injectDuplicateCandidate(e){const s=i(e);return this.emit(s,this.messageListeners),this.emit(s,this.messageListeners),s}injectAck(e,t){this.emitMessage(s.parse({type:"runtime.ack",workspaceId:e,throughRelaySequence:t}),this.messageListeners)}injectLateCandidate(e){if(!this.disconnected)throw new Error("Late candidate injection requires a disconnected transport generation");const s=i(e);return this.emit(s,this.lateMessageListeners),s}disconnect(){if(!this.disconnected){this.disconnected=!0,this.lateMessageListeners=[...this.messageListeners];for(const e of[...this.closeListeners])e()}}emit(e,s){if(this.disconnected&&s===this.messageListeners)throw new Error("Cannot inject into a disconnected in-memory Relay transport");for(const t of[...s])t(e)}emitMessage(e,s){if(this.disconnected&&s===this.messageListeners)throw new Error("Cannot inject into a disconnected in-memory Relay transport");for(const t of[...s])t(e)}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roll-agent/companion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./testing": {
|
|
19
|
+
"types": "./dist/testing.d.ts",
|
|
20
|
+
"default": "./dist/testing.js"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"publishConfig": {
|
|
@@ -27,9 +31,9 @@
|
|
|
27
31
|
},
|
|
28
32
|
"dependencies": {
|
|
29
33
|
"zod": "3.25.76",
|
|
30
|
-
"@roll-agent/client-node": "0.
|
|
31
|
-
"@roll-agent/protocol": "0.
|
|
32
|
-
"@roll-agent/relay-protocol": "0.
|
|
34
|
+
"@roll-agent/client-node": "0.3.0",
|
|
35
|
+
"@roll-agent/protocol": "0.4.0",
|
|
36
|
+
"@roll-agent/relay-protocol": "0.2.0"
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
35
39
|
"@types/node": "^22.0.0"
|