@fatagnus/remote-cmd-relay-convex 2.0.1 → 2.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fatagnus/remote-cmd-relay-convex",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Convex component for managing remote command relays - assignment management, command queuing, status tracking, and credential inventory",
5
5
  "author": "Ozan Turksever <ozan.turksever@gmail.com>",
6
6
  "license": "Apache-2.0",
@@ -457,6 +457,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
457
457
  { success: boolean },
458
458
  Name
459
459
  >;
460
+ updatePartialOutput: FunctionReference<
461
+ "mutation",
462
+ "internal",
463
+ { commandId: string; partialOutput?: string; partialStderr?: string },
464
+ { success: boolean },
465
+ Name
466
+ >;
460
467
  verifyRelay: FunctionReference<
461
468
  "query",
462
469
  "internal",
@@ -489,6 +496,30 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
489
496
  | { found: false },
490
497
  Name
491
498
  >;
499
+ getCommandStream: FunctionReference<
500
+ "query",
501
+ "internal",
502
+ { commandId: string; outputOffset?: number },
503
+ | {
504
+ done: boolean;
505
+ error?: string;
506
+ exitCode?: number;
507
+ found: true;
508
+ output?: string;
509
+ partialOutput?: string;
510
+ partialStderr?: string;
511
+ status:
512
+ | "pending"
513
+ | "claimed"
514
+ | "executing"
515
+ | "completed"
516
+ | "failed"
517
+ | "timeout";
518
+ stderr?: string;
519
+ }
520
+ | { found: false },
521
+ Name
522
+ >;
492
523
  queueRpcCommand: FunctionReference<
493
524
  "mutation",
494
525
  "internal",
@@ -185,6 +185,130 @@ describe("public", () => {
185
185
  });
186
186
  });
187
187
 
188
+ describe("updatePartialOutput", () => {
189
+ it("updates partial output for claimed command", async () => {
190
+ const cmd = await createMockCommand(t, {
191
+ status: "claimed",
192
+ claimedBy: "relay-1",
193
+ });
194
+
195
+ const result = await t.mutation(api.public.updatePartialOutput, {
196
+ commandId: cmd._id,
197
+ partialOutput: "Processing...\n",
198
+ partialStderr: "",
199
+ });
200
+
201
+ expect(result.success).toBe(true);
202
+
203
+ // Verify command was updated
204
+ const updated = await t.run(async (ctx) => {
205
+ return await ctx.db.get(cmd._id);
206
+ });
207
+
208
+ expect(updated?.status).toBe("executing");
209
+ expect(updated?.partialOutput).toBe("Processing...\n");
210
+ });
211
+
212
+ it("updates partial output for executing command", async () => {
213
+ const cmd = await createMockCommand(t, {
214
+ status: "executing",
215
+ });
216
+
217
+ await t.run(async (ctx) => {
218
+ await ctx.db.patch(cmd._id, {
219
+ partialOutput: "Line 1\n",
220
+ });
221
+ });
222
+
223
+ const result = await t.mutation(api.public.updatePartialOutput, {
224
+ commandId: cmd._id,
225
+ partialOutput: "Line 1\nLine 2\n",
226
+ });
227
+
228
+ expect(result.success).toBe(true);
229
+
230
+ const updated = await t.run(async (ctx) => {
231
+ return await ctx.db.get(cmd._id);
232
+ });
233
+
234
+ expect(updated?.partialOutput).toBe("Line 1\nLine 2\n");
235
+ });
236
+
237
+ it("updates both stdout and stderr", async () => {
238
+ const cmd = await createMockCommand(t, {
239
+ status: "claimed",
240
+ });
241
+
242
+ const result = await t.mutation(api.public.updatePartialOutput, {
243
+ commandId: cmd._id,
244
+ partialOutput: "stdout content",
245
+ partialStderr: "stderr content",
246
+ });
247
+
248
+ expect(result.success).toBe(true);
249
+
250
+ const updated = await t.run(async (ctx) => {
251
+ return await ctx.db.get(cmd._id);
252
+ });
253
+
254
+ expect(updated?.partialOutput).toBe("stdout content");
255
+ expect(updated?.partialStderr).toBe("stderr content");
256
+ });
257
+
258
+ it("fails for non-existent command", async () => {
259
+ const cmd = await createMockCommand(t, { status: "pending" });
260
+ await t.run(async (ctx) => {
261
+ await ctx.db.delete(cmd._id);
262
+ });
263
+
264
+ const result = await t.mutation(api.public.updatePartialOutput, {
265
+ commandId: cmd._id,
266
+ partialOutput: "test",
267
+ });
268
+
269
+ expect(result.success).toBe(false);
270
+ });
271
+
272
+ it("fails for completed command", async () => {
273
+ const cmd = await createMockCommand(t, {
274
+ status: "completed",
275
+ });
276
+
277
+ const result = await t.mutation(api.public.updatePartialOutput, {
278
+ commandId: cmd._id,
279
+ partialOutput: "test",
280
+ });
281
+
282
+ expect(result.success).toBe(false);
283
+ });
284
+
285
+ it("fails for failed command", async () => {
286
+ const cmd = await createMockCommand(t, {
287
+ status: "failed",
288
+ });
289
+
290
+ const result = await t.mutation(api.public.updatePartialOutput, {
291
+ commandId: cmd._id,
292
+ partialOutput: "test",
293
+ });
294
+
295
+ expect(result.success).toBe(false);
296
+ });
297
+
298
+ it("fails for pending command (not yet claimed)", async () => {
299
+ const cmd = await createMockCommand(t, {
300
+ status: "pending",
301
+ });
302
+
303
+ const result = await t.mutation(api.public.updatePartialOutput, {
304
+ commandId: cmd._id,
305
+ partialOutput: "test",
306
+ });
307
+
308
+ expect(result.success).toBe(false);
309
+ });
310
+ });
311
+
188
312
  describe("submitResult", () => {
189
313
  it("submits successful command result", async () => {
190
314
  const cmd = await createMockCommand(t, {
package/src/public.ts CHANGED
@@ -150,6 +150,46 @@ export const claimCommand = mutation({
150
150
  },
151
151
  });
152
152
 
153
+ /**
154
+ * Update partial output during command execution (for streaming)
155
+ */
156
+ export const updatePartialOutput = mutation({
157
+ args: {
158
+ commandId: v.id("commandQueue"),
159
+ partialOutput: v.optional(v.string()),
160
+ partialStderr: v.optional(v.string()),
161
+ },
162
+ returns: v.object({
163
+ success: v.boolean(),
164
+ }),
165
+ handler: async (ctx, args) => {
166
+ const cmd = await ctx.db.get(args.commandId);
167
+ if (!cmd) {
168
+ return { success: false };
169
+ }
170
+
171
+ // Only update if command is still executing
172
+ if (cmd.status !== "claimed" && cmd.status !== "executing") {
173
+ return { success: false };
174
+ }
175
+
176
+ const updates: Record<string, unknown> = {
177
+ status: "executing",
178
+ updatedAt: Date.now(),
179
+ };
180
+
181
+ if (args.partialOutput !== undefined) {
182
+ updates.partialOutput = args.partialOutput;
183
+ }
184
+ if (args.partialStderr !== undefined) {
185
+ updates.partialStderr = args.partialStderr;
186
+ }
187
+
188
+ await ctx.db.patch(args.commandId, updates);
189
+ return { success: true };
190
+ },
191
+ });
192
+
153
193
  /**
154
194
  * Submit command execution results
155
195
  */
package/src/rpc.test.ts CHANGED
@@ -293,6 +293,328 @@ describe("rpc", () => {
293
293
  });
294
294
  });
295
295
 
296
+ describe("getCommandStream", () => {
297
+ it("returns found: false for non-existent command", async () => {
298
+ const cmd = await createMockCommand(t, { status: "pending" });
299
+ await t.run(async (ctx) => {
300
+ await ctx.db.delete(cmd._id);
301
+ });
302
+
303
+ const result = await t.query(api.rpc.getCommandStream, {
304
+ commandId: cmd._id,
305
+ });
306
+
307
+ expect(result.found).toBe(false);
308
+ });
309
+
310
+ it("returns partial output for executing command", async () => {
311
+ const cmd = await createMockCommand(t, {
312
+ machineId: "machine-1",
313
+ status: "executing",
314
+ });
315
+
316
+ await t.run(async (ctx) => {
317
+ await ctx.db.patch(cmd._id, {
318
+ partialOutput: "Processing line 1\nProcessing line 2\n",
319
+ partialStderr: "Warning: something\n",
320
+ });
321
+ });
322
+
323
+ const result = await t.query(api.rpc.getCommandStream, {
324
+ commandId: cmd._id,
325
+ });
326
+
327
+ expect(result.found).toBe(true);
328
+ if (result.found) {
329
+ expect(result.status).toBe("executing");
330
+ expect(result.partialOutput).toBe("Processing line 1\nProcessing line 2\n");
331
+ expect(result.partialStderr).toBe("Warning: something\n");
332
+ expect(result.done).toBe(false);
333
+ expect(result.output).toBeUndefined();
334
+ }
335
+ });
336
+
337
+ it("returns partial output with offset", async () => {
338
+ const cmd = await createMockCommand(t, {
339
+ machineId: "machine-1",
340
+ status: "executing",
341
+ });
342
+
343
+ await t.run(async (ctx) => {
344
+ await ctx.db.patch(cmd._id, {
345
+ partialOutput: "Line 1\nLine 2\nLine 3\n",
346
+ });
347
+ });
348
+
349
+ // Request output starting from offset 7 (after "Line 1\n")
350
+ const result = await t.query(api.rpc.getCommandStream, {
351
+ commandId: cmd._id,
352
+ outputOffset: 7,
353
+ });
354
+
355
+ expect(result.found).toBe(true);
356
+ if (result.found) {
357
+ expect(result.partialOutput).toBe("Line 2\nLine 3\n");
358
+ expect(result.done).toBe(false);
359
+ }
360
+ });
361
+
362
+ it("returns final output when command is completed", async () => {
363
+ const cmd = await createMockCommand(t, {
364
+ machineId: "machine-1",
365
+ status: "completed",
366
+ });
367
+
368
+ await t.run(async (ctx) => {
369
+ await ctx.db.patch(cmd._id, {
370
+ output: "Final output\n",
371
+ stderr: "Final stderr\n",
372
+ exitCode: 0,
373
+ partialOutput: "Should be ignored",
374
+ });
375
+ });
376
+
377
+ const result = await t.query(api.rpc.getCommandStream, {
378
+ commandId: cmd._id,
379
+ });
380
+
381
+ expect(result.found).toBe(true);
382
+ if (result.found) {
383
+ expect(result.status).toBe("completed");
384
+ expect(result.output).toBe("Final output\n");
385
+ expect(result.stderr).toBe("Final stderr\n");
386
+ expect(result.exitCode).toBe(0);
387
+ expect(result.done).toBe(true);
388
+ expect(result.partialOutput).toBeUndefined();
389
+ }
390
+ });
391
+
392
+ it("returns done: true for failed command", async () => {
393
+ const cmd = await createMockCommand(t, {
394
+ machineId: "machine-1",
395
+ status: "failed",
396
+ });
397
+
398
+ await t.run(async (ctx) => {
399
+ await ctx.db.patch(cmd._id, {
400
+ stderr: "Error message",
401
+ exitCode: 1,
402
+ error: "Command failed",
403
+ });
404
+ });
405
+
406
+ const result = await t.query(api.rpc.getCommandStream, {
407
+ commandId: cmd._id,
408
+ });
409
+
410
+ expect(result.found).toBe(true);
411
+ if (result.found) {
412
+ expect(result.status).toBe("failed");
413
+ expect(result.done).toBe(true);
414
+ expect(result.error).toBe("Command failed");
415
+ }
416
+ });
417
+
418
+ it("returns done: true for timeout command", async () => {
419
+ const cmd = await createMockCommand(t, {
420
+ machineId: "machine-1",
421
+ status: "timeout",
422
+ });
423
+
424
+ await t.run(async (ctx) => {
425
+ await ctx.db.patch(cmd._id, {
426
+ error: "Command timed out",
427
+ });
428
+ });
429
+
430
+ const result = await t.query(api.rpc.getCommandStream, {
431
+ commandId: cmd._id,
432
+ });
433
+
434
+ expect(result.found).toBe(true);
435
+ if (result.found) {
436
+ expect(result.status).toBe("timeout");
437
+ expect(result.done).toBe(true);
438
+ }
439
+ });
440
+
441
+ it("returns empty partial output for pending command", async () => {
442
+ const cmd = await createMockCommand(t, {
443
+ machineId: "machine-1",
444
+ status: "pending",
445
+ });
446
+
447
+ const result = await t.query(api.rpc.getCommandStream, {
448
+ commandId: cmd._id,
449
+ });
450
+
451
+ expect(result.found).toBe(true);
452
+ if (result.found) {
453
+ expect(result.status).toBe("pending");
454
+ expect(result.done).toBe(false);
455
+ expect(result.partialOutput).toBeUndefined();
456
+ }
457
+ });
458
+
459
+ it("returns empty partial output for claimed command", async () => {
460
+ const cmd = await createMockCommand(t, {
461
+ machineId: "machine-1",
462
+ status: "claimed",
463
+ claimedBy: "relay-1",
464
+ });
465
+
466
+ const result = await t.query(api.rpc.getCommandStream, {
467
+ commandId: cmd._id,
468
+ });
469
+
470
+ expect(result.found).toBe(true);
471
+ if (result.found) {
472
+ expect(result.status).toBe("claimed");
473
+ expect(result.done).toBe(false);
474
+ }
475
+ });
476
+ });
477
+
478
+ describe("streaming workflow", () => {
479
+ it("simulates real-time streaming: queue -> claim -> stream updates -> complete", async () => {
480
+ // 1. Queue command
481
+ const queueResult = await t.mutation(api.rpc.queueRpcCommand, {
482
+ machineId: "stream-machine",
483
+ command: "long-running-command",
484
+ targetType: "local",
485
+ createdBy: "test-user",
486
+ });
487
+
488
+ expect(queueResult.success).toBe(true);
489
+ const commandId = queueResult.commandId!;
490
+
491
+ // 2. Verify command is pending, no streaming data
492
+ let streamResult = await t.query(api.rpc.getCommandStream, { commandId });
493
+ expect(streamResult.found).toBe(true);
494
+ if (streamResult.found) {
495
+ expect(streamResult.status).toBe("pending");
496
+ expect(streamResult.done).toBe(false);
497
+ }
498
+
499
+ // 3. Relay claims command
500
+ await t.mutation(api.commands.claim, {
501
+ id: commandId,
502
+ claimedBy: "relay-1",
503
+ });
504
+
505
+ // 4. Relay sends first streaming update
506
+ await t.mutation(api.public.updatePartialOutput, {
507
+ commandId,
508
+ partialOutput: "Step 1: Starting...\n",
509
+ });
510
+
511
+ streamResult = await t.query(api.rpc.getCommandStream, { commandId });
512
+ if (streamResult.found) {
513
+ expect(streamResult.status).toBe("executing");
514
+ expect(streamResult.partialOutput).toBe("Step 1: Starting...\n");
515
+ expect(streamResult.done).toBe(false);
516
+ }
517
+
518
+ // 5. Relay sends second streaming update
519
+ await t.mutation(api.public.updatePartialOutput, {
520
+ commandId,
521
+ partialOutput: "Step 1: Starting...\nStep 2: Processing...\n",
522
+ });
523
+
524
+ // Client polls with offset to get only new content
525
+ streamResult = await t.query(api.rpc.getCommandStream, {
526
+ commandId,
527
+ outputOffset: 20, // Length of first message
528
+ });
529
+ if (streamResult.found) {
530
+ expect(streamResult.partialOutput).toBe("Step 2: Processing...\n");
531
+ }
532
+
533
+ // 6. Relay sends third streaming update with stderr
534
+ await t.mutation(api.public.updatePartialOutput, {
535
+ commandId,
536
+ partialOutput: "Step 1: Starting...\nStep 2: Processing...\nStep 3: Finishing...\n",
537
+ partialStderr: "Warning: deprecated API\n",
538
+ });
539
+
540
+ streamResult = await t.query(api.rpc.getCommandStream, { commandId });
541
+ if (streamResult.found) {
542
+ expect(streamResult.partialStderr).toBe("Warning: deprecated API\n");
543
+ }
544
+
545
+ // 7. Relay completes command
546
+ await t.mutation(api.commands.complete, {
547
+ id: commandId,
548
+ success: true,
549
+ output: "Step 1: Starting...\nStep 2: Processing...\nStep 3: Finishing...\nDone!\n",
550
+ exitCode: 0,
551
+ durationMs: 5000,
552
+ });
553
+
554
+ // 8. Client gets final result
555
+ streamResult = await t.query(api.rpc.getCommandStream, { commandId });
556
+ if (streamResult.found) {
557
+ expect(streamResult.status).toBe("completed");
558
+ expect(streamResult.done).toBe(true);
559
+ expect(streamResult.output).toContain("Done!");
560
+ expect(streamResult.exitCode).toBe(0);
561
+ expect(streamResult.partialOutput).toBeUndefined();
562
+ }
563
+ });
564
+
565
+ it("streaming with command failure", async () => {
566
+ // 1. Queue command
567
+ const queueResult = await t.mutation(api.rpc.queueRpcCommand, {
568
+ machineId: "fail-stream-machine",
569
+ command: "failing-command",
570
+ targetType: "local",
571
+ createdBy: "test-user",
572
+ });
573
+
574
+ const commandId = queueResult.commandId!;
575
+
576
+ // 2. Claim and start streaming
577
+ await t.mutation(api.commands.claim, {
578
+ id: commandId,
579
+ claimedBy: "relay-1",
580
+ });
581
+
582
+ await t.mutation(api.public.updatePartialOutput, {
583
+ commandId,
584
+ partialOutput: "Starting...\n",
585
+ partialStderr: "Error: something went wrong\n",
586
+ });
587
+
588
+ // 3. Verify streaming state
589
+ let streamResult = await t.query(api.rpc.getCommandStream, { commandId });
590
+ if (streamResult.found) {
591
+ expect(streamResult.status).toBe("executing");
592
+ expect(streamResult.partialStderr).toBe("Error: something went wrong\n");
593
+ expect(streamResult.done).toBe(false);
594
+ }
595
+
596
+ // 4. Command fails
597
+ await t.mutation(api.commands.complete, {
598
+ id: commandId,
599
+ success: false,
600
+ output: "Starting...\n",
601
+ stderr: "Error: something went wrong\n",
602
+ exitCode: 1,
603
+ error: "Command failed with exit code 1",
604
+ durationMs: 500,
605
+ });
606
+
607
+ // 5. Verify final failed state
608
+ streamResult = await t.query(api.rpc.getCommandStream, { commandId });
609
+ if (streamResult.found) {
610
+ expect(streamResult.status).toBe("failed");
611
+ expect(streamResult.done).toBe(true);
612
+ expect(streamResult.stderr).toBe("Error: something went wrong\n");
613
+ expect(streamResult.exitCode).toBe(1);
614
+ }
615
+ });
616
+ });
617
+
296
618
  describe("RPC workflow integration", () => {
297
619
  it("complete RPC flow: queue -> claim -> execute -> complete -> get result", async () => {
298
620
  // 1. Queue command via RPC
package/src/rpc.ts CHANGED
@@ -95,3 +95,73 @@ export const getCommandResult = query({
95
95
  };
96
96
  },
97
97
  });
98
+
99
+ /**
100
+ * Get streaming output for a command (includes partial output during execution).
101
+ * Use this for real-time output streaming.
102
+ */
103
+ export const getCommandStream = query({
104
+ args: {
105
+ commandId: v.id("commandQueue"),
106
+ outputOffset: v.optional(v.number()), // Only return output after this offset
107
+ },
108
+ returns: v.union(
109
+ v.object({
110
+ found: v.literal(true),
111
+ status: commandStatusValidator,
112
+ partialOutput: v.optional(v.string()),
113
+ partialStderr: v.optional(v.string()),
114
+ output: v.optional(v.string()),
115
+ stderr: v.optional(v.string()),
116
+ exitCode: v.optional(v.number()),
117
+ error: v.optional(v.string()),
118
+ done: v.boolean(),
119
+ }),
120
+ v.object({
121
+ found: v.literal(false),
122
+ })
123
+ ),
124
+ handler: async (ctx, args) => {
125
+ const cmd = await ctx.db.get(args.commandId);
126
+ if (!cmd) {
127
+ return { found: false as const };
128
+ }
129
+
130
+ const isDone = cmd.status === "completed" || cmd.status === "failed" || cmd.status === "timeout";
131
+
132
+ // If command is done, return final output
133
+ if (isDone) {
134
+ return {
135
+ found: true as const,
136
+ status: cmd.status,
137
+ partialOutput: undefined,
138
+ partialStderr: undefined,
139
+ output: cmd.output,
140
+ stderr: cmd.stderr,
141
+ exitCode: cmd.exitCode,
142
+ error: cmd.error,
143
+ done: true,
144
+ };
145
+ }
146
+
147
+ // Return partial output, optionally sliced from offset
148
+ let partialOutput = cmd.partialOutput;
149
+ let partialStderr = cmd.partialStderr;
150
+
151
+ if (args.outputOffset !== undefined && partialOutput) {
152
+ partialOutput = partialOutput.slice(args.outputOffset);
153
+ }
154
+
155
+ return {
156
+ found: true as const,
157
+ status: cmd.status,
158
+ partialOutput,
159
+ partialStderr,
160
+ output: undefined,
161
+ stderr: undefined,
162
+ exitCode: undefined,
163
+ error: undefined,
164
+ done: false,
165
+ };
166
+ },
167
+ });
package/src/schema.ts CHANGED
@@ -157,6 +157,10 @@ export const tables = {
157
157
  status: commandStatusValidator,
158
158
  claimedBy: v.optional(v.string()), // Relay assignment ID that claimed
159
159
  claimedAt: v.optional(v.number()),
160
+ // Streaming output support
161
+ partialOutput: v.optional(v.string()), // Incremental stdout during execution
162
+ partialStderr: v.optional(v.string()), // Incremental stderr during execution
163
+ outputOffset: v.optional(v.number()), // Last sent output offset for streaming
160
164
  // Results
161
165
  output: v.optional(v.string()),
162
166
  stderr: v.optional(v.string()),