openclaw-remote 0.1.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 +151 -0
- package/index.ts +17 -0
- package/package.json +22 -0
- package/src/accounts.ts +80 -0
- package/src/channel.ts +544 -0
- package/src/client.ts +226 -0
- package/src/event-formatter.ts +166 -0
- package/src/realtime-listener.ts +117 -0
- package/src/runtime.ts +9 -0
- package/src/sse-listener.ts +164 -0
- package/src/types.ts +102 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Remote API.
|
|
3
|
+
* All functions use standard fetch() with Bearer auth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
RemoteAccount,
|
|
8
|
+
RemoteTask,
|
|
9
|
+
RemoteComment,
|
|
10
|
+
RemoteHeartbeat,
|
|
11
|
+
RemoteActivity,
|
|
12
|
+
ApiResult,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Build standard headers for Remote API requests. */
|
|
16
|
+
function buildHeaders(account: RemoteAccount): Record<string, string> {
|
|
17
|
+
return {
|
|
18
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
Accept: "application/json",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build the full URL for a given API path. */
|
|
25
|
+
function buildUrl(account: RemoteAccount, path: string): string {
|
|
26
|
+
return `${account.baseUrl}${path}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Safe JSON fetch wrapper with error handling. */
|
|
30
|
+
async function apiFetch<T>(
|
|
31
|
+
url: string,
|
|
32
|
+
options: RequestInit,
|
|
33
|
+
): Promise<ApiResult<T>> {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(30_000) });
|
|
36
|
+
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const body = await res.text().catch(() => "");
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: `HTTP ${res.status}: ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = (await res.json()) as T;
|
|
46
|
+
return { ok: true, data };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
return { ok: false, error: message };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Post a comment on a task.
|
|
55
|
+
*/
|
|
56
|
+
export async function postComment(
|
|
57
|
+
account: RemoteAccount,
|
|
58
|
+
taskId: string,
|
|
59
|
+
content: string,
|
|
60
|
+
): Promise<ApiResult<{ comment: RemoteComment }>> {
|
|
61
|
+
return apiFetch<{ comment: RemoteComment }>(
|
|
62
|
+
buildUrl(account, `/api/v1/tasks/${encodeURIComponent(taskId)}/comments`),
|
|
63
|
+
{
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: buildHeaders(account),
|
|
66
|
+
body: JSON.stringify({ content }),
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a new task.
|
|
73
|
+
*/
|
|
74
|
+
export async function createTask(
|
|
75
|
+
account: RemoteAccount,
|
|
76
|
+
task: {
|
|
77
|
+
title: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
type?: string;
|
|
80
|
+
priority?: string;
|
|
81
|
+
assigned_role_id?: string;
|
|
82
|
+
epic_id?: string;
|
|
83
|
+
},
|
|
84
|
+
): Promise<ApiResult<{ task: RemoteTask }>> {
|
|
85
|
+
return apiFetch<{ task: RemoteTask }>(
|
|
86
|
+
buildUrl(account, "/api/v1/tasks"),
|
|
87
|
+
{
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: buildHeaders(account),
|
|
90
|
+
body: JSON.stringify(task),
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Update an existing task.
|
|
97
|
+
*/
|
|
98
|
+
export async function updateTask(
|
|
99
|
+
account: RemoteAccount,
|
|
100
|
+
taskId: string,
|
|
101
|
+
updates: {
|
|
102
|
+
status?: string;
|
|
103
|
+
priority?: string;
|
|
104
|
+
assigned_to?: string;
|
|
105
|
+
title?: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
ping?: boolean;
|
|
108
|
+
},
|
|
109
|
+
): Promise<ApiResult<{ task: RemoteTask }>> {
|
|
110
|
+
return apiFetch<{ task: RemoteTask }>(
|
|
111
|
+
buildUrl(account, `/api/v1/tasks/${encodeURIComponent(taskId)}`),
|
|
112
|
+
{
|
|
113
|
+
method: "PATCH",
|
|
114
|
+
headers: buildHeaders(account),
|
|
115
|
+
body: JSON.stringify(updates),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List tasks with optional filters.
|
|
122
|
+
*/
|
|
123
|
+
export async function listTasks(
|
|
124
|
+
account: RemoteAccount,
|
|
125
|
+
filters?: { status?: string; assigned_to?: string },
|
|
126
|
+
): Promise<ApiResult<{ tasks: RemoteTask[] }>> {
|
|
127
|
+
const params = new URLSearchParams();
|
|
128
|
+
if (filters?.status) params.set("status", filters.status);
|
|
129
|
+
if (filters?.assigned_to) params.set("assigned_to", filters.assigned_to);
|
|
130
|
+
|
|
131
|
+
const qs = params.toString();
|
|
132
|
+
const path = `/api/v1/tasks${qs ? `?${qs}` : ""}`;
|
|
133
|
+
|
|
134
|
+
return apiFetch<{ tasks: RemoteTask[] }>(
|
|
135
|
+
buildUrl(account, path),
|
|
136
|
+
{
|
|
137
|
+
method: "GET",
|
|
138
|
+
headers: buildHeaders(account),
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get a single task with comments.
|
|
145
|
+
*/
|
|
146
|
+
export async function getTask(
|
|
147
|
+
account: RemoteAccount,
|
|
148
|
+
taskId: string,
|
|
149
|
+
): Promise<ApiResult<{ task: RemoteTask }>> {
|
|
150
|
+
return apiFetch<{ task: RemoteTask }>(
|
|
151
|
+
buildUrl(account, `/api/v1/tasks/${encodeURIComponent(taskId)}`),
|
|
152
|
+
{
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: buildHeaders(account),
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get board health / heartbeat.
|
|
161
|
+
*/
|
|
162
|
+
export async function getHeartbeat(
|
|
163
|
+
account: RemoteAccount,
|
|
164
|
+
): Promise<ApiResult<RemoteHeartbeat>> {
|
|
165
|
+
return apiFetch<RemoteHeartbeat>(
|
|
166
|
+
buildUrl(account, "/api/v1/heartbeat"),
|
|
167
|
+
{
|
|
168
|
+
method: "GET",
|
|
169
|
+
headers: buildHeaders(account),
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get activity feed.
|
|
176
|
+
*/
|
|
177
|
+
export async function getActivity(
|
|
178
|
+
account: RemoteAccount,
|
|
179
|
+
since?: string,
|
|
180
|
+
limit?: number,
|
|
181
|
+
): Promise<ApiResult<{ activity: RemoteActivity[] }>> {
|
|
182
|
+
const params = new URLSearchParams();
|
|
183
|
+
if (since) params.set("since", since);
|
|
184
|
+
if (limit != null) params.set("limit", String(limit));
|
|
185
|
+
|
|
186
|
+
const qs = params.toString();
|
|
187
|
+
const path = `/api/v1/activity${qs ? `?${qs}` : ""}`;
|
|
188
|
+
|
|
189
|
+
return apiFetch<{ activity: RemoteActivity[] }>(
|
|
190
|
+
buildUrl(account, path),
|
|
191
|
+
{
|
|
192
|
+
method: "GET",
|
|
193
|
+
headers: buildHeaders(account),
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get project info.
|
|
200
|
+
*/
|
|
201
|
+
export async function getProject(
|
|
202
|
+
account: RemoteAccount,
|
|
203
|
+
): Promise<ApiResult<any>> {
|
|
204
|
+
return apiFetch<any>(
|
|
205
|
+
buildUrl(account, "/api/v1/project"),
|
|
206
|
+
{
|
|
207
|
+
method: "GET",
|
|
208
|
+
headers: buildHeaders(account),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get roles.
|
|
215
|
+
*/
|
|
216
|
+
export async function getRoles(
|
|
217
|
+
account: RemoteAccount,
|
|
218
|
+
): Promise<ApiResult<any>> {
|
|
219
|
+
return apiFetch<any>(
|
|
220
|
+
buildUrl(account, "/api/v1/roles"),
|
|
221
|
+
{
|
|
222
|
+
method: "GET",
|
|
223
|
+
headers: buildHeaders(account),
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform raw SSE events into human-readable messages for the agent.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { RemoteEvent } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface FormattedEvent {
|
|
8
|
+
/** Human-readable message body */
|
|
9
|
+
body: string;
|
|
10
|
+
/** Task ID associated with this event */
|
|
11
|
+
taskId: string;
|
|
12
|
+
/** Sender name (if identifiable) */
|
|
13
|
+
senderName?: string;
|
|
14
|
+
/** Sender ID (if identifiable) */
|
|
15
|
+
senderId?: string;
|
|
16
|
+
/** Task title for conversation label */
|
|
17
|
+
taskTitle?: string;
|
|
18
|
+
/** Whether this event is a direct/assigned event or board-wide */
|
|
19
|
+
isDirect: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format a Remote SSE event into a human-readable message.
|
|
24
|
+
*/
|
|
25
|
+
export function formatEvent(event: RemoteEvent): FormattedEvent | null {
|
|
26
|
+
switch (event.type) {
|
|
27
|
+
case "task.created":
|
|
28
|
+
return formatTaskCreated(event);
|
|
29
|
+
case "task.updated":
|
|
30
|
+
return formatTaskUpdated(event);
|
|
31
|
+
case "task.commented":
|
|
32
|
+
return formatTaskCommented(event);
|
|
33
|
+
case "task.assigned":
|
|
34
|
+
return formatTaskAssigned(event);
|
|
35
|
+
default:
|
|
36
|
+
return formatGenericEvent(event);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatTaskCreated(event: RemoteEvent): FormattedEvent | null {
|
|
41
|
+
const task = event.task;
|
|
42
|
+
if (!task) return null;
|
|
43
|
+
|
|
44
|
+
const meta = [task.type, task.priority].filter(Boolean).join(", ");
|
|
45
|
+
let body = `📋 New task created: **${task.title}**`;
|
|
46
|
+
if (meta) body += ` [${meta}]`;
|
|
47
|
+
if (task.description) body += `\n${task.description}`;
|
|
48
|
+
body += `\n\n[task:${task.id}]`;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
body,
|
|
52
|
+
taskId: task.id,
|
|
53
|
+
taskTitle: task.title,
|
|
54
|
+
isDirect: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatTaskUpdated(event: RemoteEvent): FormattedEvent | null {
|
|
59
|
+
const task = event.task;
|
|
60
|
+
if (!task) return null;
|
|
61
|
+
|
|
62
|
+
const changes = event.changes;
|
|
63
|
+
|
|
64
|
+
// Assignment change — treat as a direct assignment event
|
|
65
|
+
if (changes?.field === "assigned_role_id" || changes?.field === "assigned_to_agent" || changes?.field === "assigned_profile_id") {
|
|
66
|
+
if (changes.to && changes.to !== "null" && changes.to !== "") {
|
|
67
|
+
let body = `👤 You've been assigned: **${task.title}**`;
|
|
68
|
+
if (task.description) body += `\n\n${task.description}`;
|
|
69
|
+
body += `\n\nPriority: ${task.priority || "medium"} | Type: ${task.type || "task"}`;
|
|
70
|
+
body += `\n\nAcknowledge this task, move it to in_progress using remote_update_task, and share your plan.`;
|
|
71
|
+
body += `\n\n[task:${task.id}]`;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
body,
|
|
75
|
+
taskId: task.id,
|
|
76
|
+
taskTitle: task.title,
|
|
77
|
+
isDirect: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Unassignment — skip silently
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Status change
|
|
85
|
+
if (changes?.field === "status") {
|
|
86
|
+
let body = `🔄 Task **${task.title}** moved to **${changes.to}**`;
|
|
87
|
+
body += `\n\n[task:${task.id}]`;
|
|
88
|
+
return {
|
|
89
|
+
body,
|
|
90
|
+
taskId: task.id,
|
|
91
|
+
taskTitle: task.title,
|
|
92
|
+
isDirect: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Generic update
|
|
97
|
+
let body = `🔄 Task updated: **${task.title}**`;
|
|
98
|
+
if (changes) {
|
|
99
|
+
body += ` — ${changes.field} changed from "${changes.from}" to "${changes.to}"`;
|
|
100
|
+
}
|
|
101
|
+
body += `\n\n[task:${task.id}]`;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
body,
|
|
105
|
+
taskId: task.id,
|
|
106
|
+
taskTitle: task.title,
|
|
107
|
+
isDirect: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatTaskCommented(event: RemoteEvent): FormattedEvent | null {
|
|
112
|
+
const comment = event.comment;
|
|
113
|
+
const taskId = event.task_id ?? event.task?.id;
|
|
114
|
+
if (!comment || !taskId) return null;
|
|
115
|
+
|
|
116
|
+
const taskTitle = event.task?.title ?? `Task ${taskId}`;
|
|
117
|
+
let body = `💬 ${comment.author_name} commented on **${taskTitle}**:\n${comment.content}`;
|
|
118
|
+
body += `\n\n[task:${taskId}]`;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
body,
|
|
122
|
+
taskId,
|
|
123
|
+
senderName: comment.author_name,
|
|
124
|
+
senderId: comment.author_id,
|
|
125
|
+
taskTitle,
|
|
126
|
+
isDirect: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatTaskAssigned(event: RemoteEvent): FormattedEvent | null {
|
|
131
|
+
const task = event.task;
|
|
132
|
+
const assignee = event.assigned_to;
|
|
133
|
+
if (!task) return null;
|
|
134
|
+
|
|
135
|
+
let body = `👤 Task **${task.title}** assigned`;
|
|
136
|
+
if (assignee) {
|
|
137
|
+
body += ` to ${assignee.name} (${assignee.role})`;
|
|
138
|
+
}
|
|
139
|
+
body += `\n\n[task:${task.id}]`;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
body,
|
|
143
|
+
taskId: task.id,
|
|
144
|
+
senderName: assignee?.name,
|
|
145
|
+
taskTitle: task.title,
|
|
146
|
+
// Assigned events are direct — the agent is likely the assignee
|
|
147
|
+
isDirect: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatGenericEvent(event: RemoteEvent): FormattedEvent | null {
|
|
152
|
+
const task = event.task;
|
|
153
|
+
const taskId = event.task_id ?? task?.id;
|
|
154
|
+
if (!taskId) return null;
|
|
155
|
+
|
|
156
|
+
const taskTitle = task?.title ?? `Task ${taskId}`;
|
|
157
|
+
let body = `🔔 Remote event (${event.type}): **${taskTitle}**`;
|
|
158
|
+
body += `\n\n[task:${taskId}]`;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
body,
|
|
162
|
+
taskId,
|
|
163
|
+
taskTitle,
|
|
164
|
+
isDirect: false,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Realtime listener for Remote board events.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to Postgres changes on task_comments (INSERT) only.
|
|
5
|
+
* All task lifecycle events (assigned, status changed, created) are
|
|
6
|
+
* delivered as system comments by the Remote server actions, so a
|
|
7
|
+
* single task_comments subscription covers everything.
|
|
8
|
+
*
|
|
9
|
+
* Replaces the old SSE + relay architecture with a direct subscription.
|
|
10
|
+
* Scales well: 1 WebSocket per agent regardless of event volume.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createClient, type RealtimeChannel, type SupabaseClient } from "@supabase/supabase-js";
|
|
14
|
+
import type { RemoteAccount, RemoteEvent } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export type RealtimeEventCallback = (event: RemoteEvent) => void;
|
|
17
|
+
export type RealtimeErrorCallback = (error: Error) => void;
|
|
18
|
+
|
|
19
|
+
interface RealtimeListenerHandle {
|
|
20
|
+
unsubscribe: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Connect to Supabase Realtime and listen for board changes.
|
|
25
|
+
*
|
|
26
|
+
* Subscribes to:
|
|
27
|
+
* - task_comments (INSERT) → all events (human comments + system notifications)
|
|
28
|
+
*
|
|
29
|
+
* Returns a handle to unsubscribe cleanly.
|
|
30
|
+
*/
|
|
31
|
+
export function connectRealtime(
|
|
32
|
+
account: RemoteAccount,
|
|
33
|
+
onEvent: RealtimeEventCallback,
|
|
34
|
+
onError: RealtimeErrorCallback,
|
|
35
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
|
|
36
|
+
): RealtimeListenerHandle {
|
|
37
|
+
const { supabaseUrl, supabaseKey } = account;
|
|
38
|
+
|
|
39
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
40
|
+
throw new Error("Supabase URL and key are required for Realtime listener. Set supabaseUrl and supabaseKey in channels.remote config.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
log?.info?.(`Connecting to Supabase Realtime: ${supabaseUrl}`);
|
|
44
|
+
|
|
45
|
+
const supabase: SupabaseClient = createClient(supabaseUrl, supabaseKey, {
|
|
46
|
+
realtime: {
|
|
47
|
+
params: { eventsPerSecond: 10 },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const channelName = `remote-${account.accountId}-${Date.now()}`;
|
|
52
|
+
|
|
53
|
+
const channel: RealtimeChannel = supabase
|
|
54
|
+
.channel(channelName)
|
|
55
|
+
|
|
56
|
+
// Listen for new comments (human comments + system notification comments)
|
|
57
|
+
.on(
|
|
58
|
+
"postgres_changes",
|
|
59
|
+
{
|
|
60
|
+
event: "INSERT",
|
|
61
|
+
schema: "public",
|
|
62
|
+
table: "task_comments",
|
|
63
|
+
},
|
|
64
|
+
(payload) => {
|
|
65
|
+
try {
|
|
66
|
+
const row = payload.new as any;
|
|
67
|
+
|
|
68
|
+
// Echo suppression: skip comments authored by agents (agent_author_id set, no human author_id).
|
|
69
|
+
// System notifications have both null — they pass through.
|
|
70
|
+
// Human comments have author_id set — they pass through.
|
|
71
|
+
if (row.agent_author_id && !row.author_id) {
|
|
72
|
+
log?.info?.(`Skipping agent-authored comment on task ${row.task_id} (echo suppression)`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const event: RemoteEvent = {
|
|
77
|
+
type: "task.commented",
|
|
78
|
+
task_id: row.task_id,
|
|
79
|
+
comment: {
|
|
80
|
+
id: row.id,
|
|
81
|
+
content: row.body || row.content || "",
|
|
82
|
+
author_name: row.author_name || "System",
|
|
83
|
+
author_id: row.author_id || "",
|
|
84
|
+
created_at: row.created_at,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
log?.info?.(`Realtime: new comment on task ${row.task_id}`);
|
|
89
|
+
onEvent(event);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
log?.warn?.(`Failed to process comment event: ${err}`);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Subscribe and handle status
|
|
97
|
+
channel.subscribe((status, err) => {
|
|
98
|
+
if (status === "SUBSCRIBED") {
|
|
99
|
+
log?.info?.("Supabase Realtime connected and subscribed.");
|
|
100
|
+
} else if (status === "CHANNEL_ERROR") {
|
|
101
|
+
log?.error?.(`Realtime channel error: ${err?.message || "unknown"}`);
|
|
102
|
+
onError(new Error(`Realtime channel error: ${err?.message || "unknown"}`));
|
|
103
|
+
} else if (status === "TIMED_OUT") {
|
|
104
|
+
log?.warn?.("Realtime subscription timed out, will auto-retry...");
|
|
105
|
+
} else if (status === "CLOSED") {
|
|
106
|
+
log?.info?.("Realtime channel closed.");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
unsubscribe: async () => {
|
|
112
|
+
log?.info?.("Unsubscribing from Supabase Realtime...");
|
|
113
|
+
await supabase.removeChannel(channel);
|
|
114
|
+
log?.info?.("Realtime listener shut down cleanly.");
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/compat";
|
|
3
|
+
|
|
4
|
+
const { setRuntime: setRemoteRuntime, getRuntime: getRemoteRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>(
|
|
6
|
+
"Remote runtime not initialized - plugin not registered",
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export { getRemoteRuntime, setRemoteRuntime };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE connection manager for Remote API events.
|
|
3
|
+
*
|
|
4
|
+
* Connects to GET /api/v1/events with streaming response body.
|
|
5
|
+
* Parses SSE data: lines into RemoteEvent objects.
|
|
6
|
+
* Auto-reconnects with exponential backoff on disconnect.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RemoteAccount, RemoteEvent } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const MIN_RECONNECT_MS = 1_000;
|
|
12
|
+
const MAX_RECONNECT_MS = 60_000;
|
|
13
|
+
const BACKOFF_FACTOR = 2;
|
|
14
|
+
|
|
15
|
+
export type SSEEventCallback = (event: RemoteEvent) => void;
|
|
16
|
+
export type SSEErrorCallback = (error: Error) => void;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Connect to the Remote SSE events stream.
|
|
20
|
+
*
|
|
21
|
+
* Returns a promise that resolves when the connection is cleanly closed
|
|
22
|
+
* (via abortSignal). Auto-reconnects on errors/disconnects until aborted.
|
|
23
|
+
*/
|
|
24
|
+
export async function connectSSE(
|
|
25
|
+
account: RemoteAccount,
|
|
26
|
+
onEvent: SSEEventCallback,
|
|
27
|
+
onError: SSEErrorCallback,
|
|
28
|
+
abortSignal?: AbortSignal,
|
|
29
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
let reconnectMs = MIN_RECONNECT_MS;
|
|
32
|
+
|
|
33
|
+
while (!abortSignal?.aborted) {
|
|
34
|
+
try {
|
|
35
|
+
await connectOnce(account, onEvent, abortSignal, log);
|
|
36
|
+
// If connectOnce resolves without error, the stream ended cleanly.
|
|
37
|
+
// Reset backoff on successful connection that lasted a while.
|
|
38
|
+
reconnectMs = MIN_RECONNECT_MS;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (abortSignal?.aborted) break;
|
|
41
|
+
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
log?.warn?.(`SSE connection error: ${message}. Reconnecting in ${reconnectMs}ms...`);
|
|
44
|
+
onError(err instanceof Error ? err : new Error(message));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (abortSignal?.aborted) break;
|
|
48
|
+
|
|
49
|
+
// Wait before reconnecting
|
|
50
|
+
await sleep(reconnectMs, abortSignal);
|
|
51
|
+
reconnectMs = Math.min(reconnectMs * BACKOFF_FACTOR, MAX_RECONNECT_MS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
log?.info?.("SSE listener shut down cleanly.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Single SSE connection attempt. Resolves when the stream ends.
|
|
59
|
+
* Throws on connection/parse errors.
|
|
60
|
+
*/
|
|
61
|
+
async function connectOnce(
|
|
62
|
+
account: RemoteAccount,
|
|
63
|
+
onEvent: SSEEventCallback,
|
|
64
|
+
abortSignal?: AbortSignal,
|
|
65
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void },
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const url = `${account.baseUrl}/api/v1/events`;
|
|
68
|
+
log?.info?.(`Connecting to SSE: ${url}`);
|
|
69
|
+
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
74
|
+
Accept: "text/event-stream",
|
|
75
|
+
"Cache-Control": "no-cache",
|
|
76
|
+
},
|
|
77
|
+
signal: abortSignal,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const body = await res.text().catch(() => "");
|
|
82
|
+
throw new Error(`SSE connection failed: HTTP ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!res.body) {
|
|
86
|
+
throw new Error("SSE response has no body stream");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
log?.info?.("SSE connected successfully.");
|
|
90
|
+
|
|
91
|
+
const reader = res.body.getReader();
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
let buffer = "";
|
|
94
|
+
let currentEventType = "";
|
|
95
|
+
let currentData = "";
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
while (true) {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (done) break;
|
|
101
|
+
|
|
102
|
+
buffer += decoder.decode(value, { stream: true });
|
|
103
|
+
|
|
104
|
+
// Process complete lines
|
|
105
|
+
const lines = buffer.split("\n");
|
|
106
|
+
// Keep the last incomplete line in the buffer
|
|
107
|
+
buffer = lines.pop() ?? "";
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line.startsWith("event:")) {
|
|
111
|
+
currentEventType = line.slice(6).trim();
|
|
112
|
+
} else if (line.startsWith("data:")) {
|
|
113
|
+
// Append to current data (SSE allows multi-line data)
|
|
114
|
+
const dataContent = line.slice(5).trim();
|
|
115
|
+
currentData = currentData ? `${currentData}\n${dataContent}` : dataContent;
|
|
116
|
+
} else if (line.startsWith(":")) {
|
|
117
|
+
// SSE comment / keepalive — ignore
|
|
118
|
+
} else if (line.trim() === "") {
|
|
119
|
+
// Empty line = event boundary. Dispatch if we have data.
|
|
120
|
+
if (currentData) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(currentData) as RemoteEvent;
|
|
123
|
+
// If the event type was specified via `event:` line, use it
|
|
124
|
+
if (currentEventType && !parsed.type) {
|
|
125
|
+
parsed.type = currentEventType;
|
|
126
|
+
}
|
|
127
|
+
onEvent(parsed);
|
|
128
|
+
} catch (parseErr) {
|
|
129
|
+
log?.warn?.(
|
|
130
|
+
`Failed to parse SSE data: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Reset for next event
|
|
135
|
+
currentEventType = "";
|
|
136
|
+
currentData = "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
reader.releaseLock();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Sleep that respects an AbortSignal. */
|
|
146
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
if (signal?.aborted) {
|
|
149
|
+
resolve();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const timer = setTimeout(resolve, ms);
|
|
154
|
+
|
|
155
|
+
signal?.addEventListener(
|
|
156
|
+
"abort",
|
|
157
|
+
() => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
resolve();
|
|
160
|
+
},
|
|
161
|
+
{ once: true },
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|