@fatagnus/remote-cmd-relay-convex 2.0.0 → 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 +1 -1
- package/src/_generated/component.ts +31 -0
- package/src/_generated/dataModel.ts +1 -1
- package/src/public.test.ts +124 -0
- package/src/public.ts +40 -0
- package/src/rpc.test.ts +322 -0
- package/src/rpc.ts +70 -0
- package/src/schema.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fatagnus/remote-cmd-relay-convex",
|
|
3
|
-
"version": "2.0.
|
|
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",
|
|
@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
|
|
|
38
38
|
* Convex documents are uniquely identified by their `Id`, which is accessible
|
|
39
39
|
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
|
40
40
|
*
|
|
41
|
-
* Documents can be loaded using `db.get(
|
|
41
|
+
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
|
42
42
|
*
|
|
43
43
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
|
44
44
|
* strings when type checking.
|
package/src/public.test.ts
CHANGED
|
@@ -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()),
|