@fatagnus/remote-cmd-relay-convex 1.0.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.
@@ -0,0 +1,308 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { createRelayTestConvex, RelayTestConvex } from "./test.setup";
3
+ import { createMockRelayStatus } from "./test.helpers";
4
+ import { api } from "./_generated/api";
5
+
6
+ describe("status", () => {
7
+ let t: RelayTestConvex;
8
+
9
+ beforeEach(() => {
10
+ vi.useFakeTimers();
11
+ t = createRelayTestConvex();
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ describe("reportStatus", () => {
20
+ it("creates new relay status record", async () => {
21
+ const result = await t.mutation(api.status.reportStatus, {
22
+ relayId: "new-relay",
23
+ capabilities: ["ssh", "local_cmd"],
24
+ version: "1.0.0",
25
+ hostname: "relay-host",
26
+ platform: "linux",
27
+ });
28
+
29
+ expect(result.success).toBe(true);
30
+ expect(result.statusId).toBeDefined();
31
+
32
+ const status = await t.query(api.status.getByRelayId, {
33
+ relayId: "new-relay",
34
+ });
35
+
36
+ expect(status).not.toBeNull();
37
+ expect(status?.capabilities).toEqual(["ssh", "local_cmd"]);
38
+ expect(status?.version).toBe("1.0.0");
39
+ expect(status?.hostname).toBe("relay-host");
40
+ expect(status?.platform).toBe("linux");
41
+ });
42
+
43
+ it("updates existing relay status record", async () => {
44
+ // Create initial status
45
+ await t.mutation(api.status.reportStatus, {
46
+ relayId: "update-relay",
47
+ capabilities: ["local_cmd"],
48
+ version: "1.0.0",
49
+ });
50
+
51
+ // Update status
52
+ const result = await t.mutation(api.status.reportStatus, {
53
+ relayId: "update-relay",
54
+ capabilities: ["ssh", "local_cmd", "perf_metrics"],
55
+ version: "2.0.0",
56
+ hostname: "new-hostname",
57
+ });
58
+
59
+ expect(result.success).toBe(true);
60
+
61
+ const status = await t.query(api.status.getByRelayId, {
62
+ relayId: "update-relay",
63
+ });
64
+
65
+ expect(status?.capabilities).toEqual(["ssh", "local_cmd", "perf_metrics"]);
66
+ expect(status?.version).toBe("2.0.0");
67
+ expect(status?.hostname).toBe("new-hostname");
68
+ });
69
+
70
+ it("reports status with metrics", async () => {
71
+ const result = await t.mutation(api.status.reportStatus, {
72
+ relayId: "metrics-relay",
73
+ capabilities: ["perf_metrics"],
74
+ metrics: {
75
+ cpuPercent: 45.5,
76
+ memoryPercent: 60.2,
77
+ memoryUsedMb: 4096,
78
+ memoryTotalMb: 8192,
79
+ diskPercent: 75.0,
80
+ loadAvg1m: 1.5,
81
+ loadAvg5m: 1.2,
82
+ loadAvg15m: 0.9,
83
+ },
84
+ });
85
+
86
+ expect(result.success).toBe(true);
87
+
88
+ const status = await t.query(api.status.getByRelayId, {
89
+ relayId: "metrics-relay",
90
+ });
91
+
92
+ expect(status?.metrics?.cpuPercent).toBe(45.5);
93
+ expect(status?.metrics?.memoryPercent).toBe(60.2);
94
+ expect(status?.metrics?.loadAvg1m).toBe(1.5);
95
+ });
96
+
97
+ it("updates lastHeartbeatAt on status report", async () => {
98
+ await t.mutation(api.status.reportStatus, {
99
+ relayId: "heartbeat-relay",
100
+ capabilities: ["local_cmd"],
101
+ });
102
+
103
+ const status = await t.query(api.status.getByRelayId, {
104
+ relayId: "heartbeat-relay",
105
+ });
106
+
107
+ expect(status?.lastHeartbeatAt).toBeDefined();
108
+ expect(status?.lastHeartbeatAt).toBeGreaterThan(0);
109
+ });
110
+ });
111
+
112
+ describe("getByRelayId", () => {
113
+ it("returns status when found", async () => {
114
+ await createMockRelayStatus(t, {
115
+ relayId: "find-me-relay",
116
+ capabilities: ["ssh", "local_cmd"],
117
+ version: "1.0.0",
118
+ });
119
+
120
+ const status = await t.query(api.status.getByRelayId, {
121
+ relayId: "find-me-relay",
122
+ });
123
+
124
+ expect(status).not.toBeNull();
125
+ expect(status?.relayId).toBe("find-me-relay");
126
+ expect(status?.capabilities).toEqual(["ssh", "local_cmd"]);
127
+ });
128
+
129
+ it("returns null when not found", async () => {
130
+ const status = await t.query(api.status.getByRelayId, {
131
+ relayId: "nonexistent-relay",
132
+ });
133
+
134
+ expect(status).toBeNull();
135
+ });
136
+ });
137
+
138
+ describe("listAll", () => {
139
+ it("lists all relay statuses", async () => {
140
+ await createMockRelayStatus(t, { relayId: "relay-1" });
141
+ await createMockRelayStatus(t, { relayId: "relay-2" });
142
+ await createMockRelayStatus(t, { relayId: "relay-3" });
143
+
144
+ const statuses = await t.query(api.status.listAll, {});
145
+
146
+ expect(statuses).toHaveLength(3);
147
+ });
148
+
149
+ it("includes isOnline flag based on heartbeat", async () => {
150
+ const now = Date.now();
151
+
152
+ // Create online relay (recent heartbeat)
153
+ await createMockRelayStatus(t, {
154
+ relayId: "online-relay",
155
+ lastHeartbeatAt: now - 30000, // 30 seconds ago
156
+ });
157
+
158
+ // Create offline relay (old heartbeat)
159
+ await createMockRelayStatus(t, {
160
+ relayId: "offline-relay",
161
+ lastHeartbeatAt: now - 120000, // 2 minutes ago
162
+ });
163
+
164
+ const statuses = await t.query(api.status.listAll, {});
165
+
166
+ const onlineRelay = statuses.find((s) => s.relayId === "online-relay");
167
+ const offlineRelay = statuses.find((s) => s.relayId === "offline-relay");
168
+
169
+ expect(onlineRelay?.isOnline).toBe(true);
170
+ expect(offlineRelay?.isOnline).toBe(false);
171
+ });
172
+
173
+ it("returns empty array when no statuses exist", async () => {
174
+ const statuses = await t.query(api.status.listAll, {});
175
+
176
+ expect(statuses).toEqual([]);
177
+ });
178
+ });
179
+
180
+ describe("findByCapability", () => {
181
+ it("finds relays with specific capability", async () => {
182
+ await createMockRelayStatus(t, {
183
+ relayId: "ssh-relay-1",
184
+ capabilities: ["ssh", "local_cmd"],
185
+ });
186
+ await createMockRelayStatus(t, {
187
+ relayId: "ssh-relay-2",
188
+ capabilities: ["ssh"],
189
+ });
190
+ await createMockRelayStatus(t, {
191
+ relayId: "local-only-relay",
192
+ capabilities: ["local_cmd"],
193
+ });
194
+
195
+ const sshRelays = await t.query(api.status.findByCapability, {
196
+ capability: "ssh",
197
+ });
198
+
199
+ expect(sshRelays).toHaveLength(2);
200
+ expect(sshRelays.map((r) => r.relayId)).toContain("ssh-relay-1");
201
+ expect(sshRelays.map((r) => r.relayId)).toContain("ssh-relay-2");
202
+ });
203
+
204
+ it("finds relays with perf_metrics capability", async () => {
205
+ await createMockRelayStatus(t, {
206
+ relayId: "metrics-relay",
207
+ capabilities: ["local_cmd", "perf_metrics"],
208
+ });
209
+ await createMockRelayStatus(t, {
210
+ relayId: "no-metrics-relay",
211
+ capabilities: ["local_cmd"],
212
+ });
213
+
214
+ const metricsRelays = await t.query(api.status.findByCapability, {
215
+ capability: "perf_metrics",
216
+ });
217
+
218
+ expect(metricsRelays).toHaveLength(1);
219
+ expect(metricsRelays[0].relayId).toBe("metrics-relay");
220
+ });
221
+
222
+ it("includes isOnline flag in results", async () => {
223
+ const now = Date.now();
224
+
225
+ await createMockRelayStatus(t, {
226
+ relayId: "online-ssh-relay",
227
+ capabilities: ["ssh"],
228
+ lastHeartbeatAt: now - 30000,
229
+ });
230
+
231
+ await createMockRelayStatus(t, {
232
+ relayId: "offline-ssh-relay",
233
+ capabilities: ["ssh"],
234
+ lastHeartbeatAt: now - 120000,
235
+ });
236
+
237
+ const relays = await t.query(api.status.findByCapability, {
238
+ capability: "ssh",
239
+ });
240
+
241
+ const online = relays.find((r) => r.relayId === "online-ssh-relay");
242
+ const offline = relays.find((r) => r.relayId === "offline-ssh-relay");
243
+
244
+ expect(online?.isOnline).toBe(true);
245
+ expect(offline?.isOnline).toBe(false);
246
+ });
247
+
248
+ it("returns empty array when no relays have capability", async () => {
249
+ await createMockRelayStatus(t, {
250
+ relayId: "local-only",
251
+ capabilities: ["local_cmd"],
252
+ });
253
+
254
+ const relays = await t.query(api.status.findByCapability, {
255
+ capability: "ssh",
256
+ });
257
+
258
+ expect(relays).toEqual([]);
259
+ });
260
+ });
261
+
262
+ describe("status lifecycle", () => {
263
+ it("tracks relay status through multiple updates", async () => {
264
+ const relayId = "lifecycle-relay";
265
+
266
+ // Initial registration
267
+ await t.mutation(api.status.reportStatus, {
268
+ relayId,
269
+ capabilities: ["local_cmd"],
270
+ version: "1.0.0",
271
+ platform: "linux",
272
+ });
273
+
274
+ let status = await t.query(api.status.getByRelayId, { relayId });
275
+ expect(status?.version).toBe("1.0.0");
276
+ expect(status?.capabilities).toEqual(["local_cmd"]);
277
+
278
+ // Upgrade with new capabilities
279
+ await t.mutation(api.status.reportStatus, {
280
+ relayId,
281
+ capabilities: ["local_cmd", "ssh"],
282
+ version: "1.1.0",
283
+ platform: "linux",
284
+ });
285
+
286
+ status = await t.query(api.status.getByRelayId, { relayId });
287
+ expect(status?.version).toBe("1.1.0");
288
+ expect(status?.capabilities).toContain("ssh");
289
+
290
+ // Add metrics capability
291
+ await t.mutation(api.status.reportStatus, {
292
+ relayId,
293
+ capabilities: ["local_cmd", "ssh", "perf_metrics"],
294
+ version: "1.2.0",
295
+ platform: "linux",
296
+ metrics: {
297
+ cpuPercent: 25.0,
298
+ memoryPercent: 50.0,
299
+ },
300
+ });
301
+
302
+ status = await t.query(api.status.getByRelayId, { relayId });
303
+ expect(status?.version).toBe("1.2.0");
304
+ expect(status?.capabilities).toHaveLength(3);
305
+ expect(status?.metrics?.cpuPercent).toBe(25.0);
306
+ });
307
+ });
308
+ });
package/src/status.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server";
3
+ import { capabilityValidator, metricsValidator } from "./schema";
4
+
5
+ /**
6
+ * Report relay status (capabilities, metrics, etc.)
7
+ * Called by relay on startup and with each heartbeat
8
+ */
9
+ export const reportStatus = mutation({
10
+ args: {
11
+ relayId: v.string(),
12
+ capabilities: v.array(capabilityValidator),
13
+ metrics: v.optional(metricsValidator),
14
+ version: v.optional(v.string()),
15
+ hostname: v.optional(v.string()),
16
+ platform: v.optional(v.string()),
17
+ },
18
+ returns: v.object({
19
+ success: v.boolean(),
20
+ statusId: v.optional(v.string()),
21
+ }),
22
+ handler: async (ctx, args) => {
23
+ const now = Date.now();
24
+
25
+ // Check if status record exists for this relay
26
+ const existing = await ctx.db
27
+ .query("relayStatus")
28
+ .withIndex("by_relayId", (q) => q.eq("relayId", args.relayId))
29
+ .first();
30
+
31
+ if (existing) {
32
+ // Update existing record
33
+ await ctx.db.patch(existing._id, {
34
+ capabilities: args.capabilities,
35
+ metrics: args.metrics,
36
+ version: args.version,
37
+ hostname: args.hostname,
38
+ platform: args.platform,
39
+ lastHeartbeatAt: now,
40
+ updatedAt: now,
41
+ });
42
+ return { success: true, statusId: existing._id };
43
+ } else {
44
+ // Create new record
45
+ const id = await ctx.db.insert("relayStatus", {
46
+ relayId: args.relayId,
47
+ capabilities: args.capabilities,
48
+ metrics: args.metrics,
49
+ version: args.version,
50
+ hostname: args.hostname,
51
+ platform: args.platform,
52
+ lastHeartbeatAt: now,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ });
56
+ return { success: true, statusId: id };
57
+ }
58
+ },
59
+ });
60
+
61
+ /**
62
+ * Get relay status by relay ID
63
+ */
64
+ export const getByRelayId = query({
65
+ args: {
66
+ relayId: v.string(),
67
+ },
68
+ returns: v.union(
69
+ v.object({
70
+ _id: v.id("relayStatus"),
71
+ relayId: v.string(),
72
+ capabilities: v.array(capabilityValidator),
73
+ metrics: v.optional(metricsValidator),
74
+ version: v.optional(v.string()),
75
+ hostname: v.optional(v.string()),
76
+ platform: v.optional(v.string()),
77
+ lastHeartbeatAt: v.number(),
78
+ createdAt: v.number(),
79
+ updatedAt: v.number(),
80
+ }),
81
+ v.null()
82
+ ),
83
+ handler: async (ctx, args) => {
84
+ const status = await ctx.db
85
+ .query("relayStatus")
86
+ .withIndex("by_relayId", (q) => q.eq("relayId", args.relayId))
87
+ .first();
88
+
89
+ if (!status) return null;
90
+
91
+ return {
92
+ _id: status._id,
93
+ relayId: status.relayId,
94
+ capabilities: status.capabilities,
95
+ metrics: status.metrics,
96
+ version: status.version,
97
+ hostname: status.hostname,
98
+ platform: status.platform,
99
+ lastHeartbeatAt: status.lastHeartbeatAt,
100
+ createdAt: status.createdAt,
101
+ updatedAt: status.updatedAt,
102
+ };
103
+ },
104
+ });
105
+
106
+ /**
107
+ * List all relay statuses
108
+ */
109
+ export const listAll = query({
110
+ args: {},
111
+ returns: v.array(
112
+ v.object({
113
+ _id: v.id("relayStatus"),
114
+ relayId: v.string(),
115
+ capabilities: v.array(capabilityValidator),
116
+ metrics: v.optional(metricsValidator),
117
+ version: v.optional(v.string()),
118
+ hostname: v.optional(v.string()),
119
+ platform: v.optional(v.string()),
120
+ lastHeartbeatAt: v.number(),
121
+ isOnline: v.boolean(),
122
+ })
123
+ ),
124
+ handler: async (ctx) => {
125
+ const statuses = await ctx.db.query("relayStatus").collect();
126
+ const now = Date.now();
127
+ const ONLINE_THRESHOLD_MS = 60000; // 1 minute
128
+
129
+ return statuses.map((s) => ({
130
+ _id: s._id,
131
+ relayId: s.relayId,
132
+ capabilities: s.capabilities,
133
+ metrics: s.metrics,
134
+ version: s.version,
135
+ hostname: s.hostname,
136
+ platform: s.platform,
137
+ lastHeartbeatAt: s.lastHeartbeatAt,
138
+ isOnline: now - s.lastHeartbeatAt < ONLINE_THRESHOLD_MS,
139
+ }));
140
+ },
141
+ });
142
+
143
+ /**
144
+ * Find relays that have a specific capability
145
+ */
146
+ export const findByCapability = query({
147
+ args: {
148
+ capability: capabilityValidator,
149
+ },
150
+ returns: v.array(
151
+ v.object({
152
+ relayId: v.string(),
153
+ capabilities: v.array(capabilityValidator),
154
+ lastHeartbeatAt: v.number(),
155
+ isOnline: v.boolean(),
156
+ })
157
+ ),
158
+ handler: async (ctx, args) => {
159
+ const statuses = await ctx.db.query("relayStatus").collect();
160
+ const now = Date.now();
161
+ const ONLINE_THRESHOLD_MS = 60000;
162
+
163
+ return statuses
164
+ .filter((s) => s.capabilities.includes(args.capability))
165
+ .map((s) => ({
166
+ relayId: s.relayId,
167
+ capabilities: s.capabilities,
168
+ lastHeartbeatAt: s.lastHeartbeatAt,
169
+ isOnline: now - s.lastHeartbeatAt < ONLINE_THRESHOLD_MS,
170
+ }));
171
+ },
172
+ });