@qodalis/cli-server-jobs 2.0.0-beta.9 → 2.0.0-rc.1

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/public-api.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { DefaultLibraryAuthor, CliIcon, CliForegroundColor, ICliCompletionProvider_TOKEN } from '@qodalis/cli-core';
1
+ import { DefaultLibraryAuthor, CliIcon, writeWrapped, writeUsage, writeExamples, CliForegroundColor, joinShellWords, resolveServerWebSocketUrl, resolveServerHeaders, ICliCompletionProvider_TOKEN } from '@qodalis/cli-core';
2
2
 
3
3
  // src/lib/processors/cli-jobs-command-processor.ts
4
4
 
@@ -91,7 +91,7 @@ var CliJobsService = class {
91
91
 
92
92
  // src/lib/processors/cli-jobs-command-processor.ts
93
93
  function getServices(context, args) {
94
- const manager = context.services.get("cli-server-manager");
94
+ const manager = context.services.getRequired("cli-server-manager");
95
95
  if (!manager || !manager.connections) {
96
96
  return [];
97
97
  }
@@ -102,7 +102,7 @@ function getServices(context, args) {
102
102
  if (!connection.connected) continue;
103
103
  const config = connection.config;
104
104
  const baseUrl = config.url.endsWith("/") ? config.url.slice(0, -1) : config.url;
105
- const headers = config.headers ?? {};
105
+ const headers = resolveServerHeaders(context.services, name, config.headers);
106
106
  results.push({ name, service: new CliJobsService(baseUrl, headers) });
107
107
  }
108
108
  return results;
@@ -115,10 +115,41 @@ async function resolveJobId(service, nameOrId) {
115
115
  }
116
116
  }
117
117
  const jobs = await service.listJobs();
118
- const match = jobs.find(
119
- (j) => j.name.toLowerCase() === nameOrId.toLowerCase() || j.id === nameOrId
118
+ const lower = nameOrId.toLowerCase();
119
+ return jobs.find((j) => j.id === nameOrId) ?? jobs.find((j) => j.name === nameOrId) ?? jobs.find((j) => j.name.toLowerCase() === lower) ?? null;
120
+ }
121
+ async function findJobOnServers(context, services, nameOrId) {
122
+ const matches = [];
123
+ const missing = [];
124
+ for (const { name, service } of services) {
125
+ try {
126
+ const job = await resolveJobId(service, nameOrId);
127
+ if (job) {
128
+ matches.push({ name, service, job });
129
+ } else {
130
+ missing.push(name);
131
+ }
132
+ } catch (e) {
133
+ context.writer.writeError(`Error on server ${name}: ${e.message}`);
134
+ }
135
+ }
136
+ if (matches.length === 0 && missing.length > 0) {
137
+ if (missing.length === services.length) {
138
+ context.writer.writeError(
139
+ services.length === 1 ? `Job "${nameOrId}" not found on server ${missing[0]}.` : `Job "${nameOrId}" not found on any of the ${services.length} connected servers.`
140
+ );
141
+ } else {
142
+ context.writer.writeError(
143
+ `Job "${nameOrId}" not found on ${missing.length === 1 ? "server" : "servers"} ${missing.join(", ")}.`
144
+ );
145
+ }
146
+ }
147
+ return matches;
148
+ }
149
+ function writeServerHeader(context, name) {
150
+ context.writer.writeln(
151
+ context.writer.wrapInColor(`Server: ${name}`, CliForegroundColor.Cyan)
120
152
  );
121
- return match ?? null;
122
153
  }
123
154
  function formatRelativeTime(isoString) {
124
155
  if (!isoString) return "-";
@@ -193,6 +224,26 @@ var CliJobsCommandProcessor = class {
193
224
  command: "jobs",
194
225
  description: "Manage background jobs on connected servers",
195
226
  author: DefaultLibraryAuthor,
227
+ writeDescription: (context) => {
228
+ const t = context.translator;
229
+ writeWrapped(
230
+ context,
231
+ t.t("cli.server.jobs.help.see_control_background_jobs", "See and control the background jobs that run on connected servers: list them, run one now, pause or stop it, read its history and logs, change its settings, or watch events live. A job is picked by name (any case) or ID and looked up on every connected server; actions apply to each server that has the job, and --server limits a command to one server.")
232
+ );
233
+ context.writer.writeln();
234
+ writeUsage(context, [
235
+ ["server jobs", t.t("cli.server.jobs.help.same_server_jobs_list", "Same as server jobs list")],
236
+ ["server jobs <action> <name|id>", "info, trigger, pause, resume, stop, cancel, history, logs, edit"],
237
+ ["server jobs watch", t.t("cli.server.jobs.help.live_job_events", "Live job events")]
238
+ ]);
239
+ context.writer.writeln();
240
+ writeExamples(context, [
241
+ ["server jobs", t.t("cli.server.jobs.help.all_jobs", "All jobs")],
242
+ ["server jobs trigger cleanup", t.t("cli.server.jobs.help.run_cleanup_job_now", "Run the cleanup job now")],
243
+ ["server jobs history cleanup --status failed", t.t("cli.server.jobs.help.failed_runs", "Its failed runs")],
244
+ ["server jobs logs cleanup --server node", t.t("cli.server.jobs.help.last_run_output_node", "Last run output, on node")]
245
+ ]);
246
+ },
196
247
  metadata: {
197
248
  icon: CliIcon.Gear,
198
249
  module: "@qodalis/cli-server-jobs"
@@ -203,6 +254,23 @@ var CliJobsCommandProcessor = class {
203
254
  {
204
255
  command: "list",
205
256
  description: "List all jobs across connected servers",
257
+ writeDescription: (context) => {
258
+ const t = context.translator;
259
+ writeWrapped(
260
+ context,
261
+ t.t("cli.server.jobs.list.help.list_jobs_every_connected", "List the jobs of every connected server: group, status, schedule, last and next run, and how long the last run took. With several servers, each gets its own table.")
262
+ );
263
+ context.writer.writeln();
264
+ writeUsage(context, [
265
+ ["server jobs list", t.t("cli.server.jobs.list.help.jobs_all_connected_servers", "Jobs on all connected servers")],
266
+ ["server jobs ls --group <name>", t.t("cli.server.jobs.list.help.only_one_group", "Only one group")]
267
+ ]);
268
+ context.writer.writeln();
269
+ writeExamples(context, [
270
+ ["server jobs ls --server node", t.t("cli.server.jobs.list.help.jobs_node_server", "Jobs on the node server")],
271
+ ["server jobs list --group reports", t.t("cli.server.jobs.list.help.jobs_reports_group", "Jobs in the reports group")]
272
+ ]);
273
+ },
206
274
  aliases: ["ls"],
207
275
  parameters: [
208
276
  serverParam,
@@ -221,6 +289,21 @@ var CliJobsCommandProcessor = class {
221
289
  {
222
290
  command: "info",
223
291
  description: "Show detailed information about a job",
292
+ writeDescription: (context) => {
293
+ const t = context.translator;
294
+ writeWrapped(
295
+ context,
296
+ t.t("cli.server.jobs.info.help.show_everything_about_one", "Show everything about one job: schedule, retries, timeout, overlap policy, the execution running now and the result of the last run. The job is found by name (any case) or ID.")
297
+ );
298
+ context.writer.writeln();
299
+ writeUsage(context, [
300
+ ["server jobs info <name|id>", ""]
301
+ ]);
302
+ context.writer.writeln();
303
+ writeExamples(context, [
304
+ ["server jobs info cleanup", t.t("cli.server.jobs.info.help.details_cleanup_job", "Details of the cleanup job")]
305
+ ]);
306
+ },
224
307
  valueRequired: true,
225
308
  parameters: [serverParam],
226
309
  processCommand: async (cmd, context) => {
@@ -231,6 +314,22 @@ var CliJobsCommandProcessor = class {
231
314
  {
232
315
  command: "trigger",
233
316
  description: "Trigger immediate execution of a job",
317
+ writeDescription: (context) => {
318
+ const t = context.translator;
319
+ writeWrapped(
320
+ context,
321
+ t.t("cli.server.jobs.trigger.help.run_job_now_without", "Run a job now, without waiting for its next scheduled time. Its schedule does not change.")
322
+ );
323
+ context.writer.writeln();
324
+ writeUsage(context, [
325
+ ["server jobs trigger <name|id>", ""]
326
+ ]);
327
+ context.writer.writeln();
328
+ writeExamples(context, [
329
+ ["server jobs trigger cleanup", t.t("cli.server.jobs.trigger.help.run_cleanup_now", "Run cleanup now")],
330
+ ["server jobs logs cleanup", t.t("cli.server.jobs.trigger.help.then_read_output", "Then read its output")]
331
+ ]);
332
+ },
234
333
  valueRequired: true,
235
334
  parameters: [serverParam],
236
335
  processCommand: async (cmd, context) => {
@@ -241,6 +340,22 @@ var CliJobsCommandProcessor = class {
241
340
  {
242
341
  command: "pause",
243
342
  description: "Pause scheduled execution of a job",
343
+ writeDescription: (context) => {
344
+ const t = context.translator;
345
+ writeWrapped(
346
+ context,
347
+ t.t("cli.server.jobs.pause.help.stop_running_job_schedule", "Stop running a job on its schedule until it is resumed. Use cancel or stop for a run that is already in progress.")
348
+ );
349
+ context.writer.writeln();
350
+ writeUsage(context, [
351
+ ["server jobs pause <name|id>", ""]
352
+ ]);
353
+ context.writer.writeln();
354
+ writeExamples(context, [
355
+ ["server jobs pause cleanup", t.t("cli.server.jobs.pause.help.hold_cleanup", "Hold cleanup")],
356
+ ["server jobs resume cleanup", t.t("cli.server.jobs.pause.help.put_back_schedule", "Put it back on schedule")]
357
+ ]);
358
+ },
244
359
  valueRequired: true,
245
360
  parameters: [serverParam],
246
361
  processCommand: async (cmd, context) => {
@@ -251,6 +366,21 @@ var CliJobsCommandProcessor = class {
251
366
  {
252
367
  command: "resume",
253
368
  description: "Resume a paused job",
369
+ writeDescription: (context) => {
370
+ const t = context.translator;
371
+ writeWrapped(
372
+ context,
373
+ t.t("cli.server.jobs.resume.help.put_paused_job_back", "Put a paused job back on its schedule.")
374
+ );
375
+ context.writer.writeln();
376
+ writeUsage(context, [
377
+ ["server jobs resume <name|id>", ""]
378
+ ]);
379
+ context.writer.writeln();
380
+ writeExamples(context, [
381
+ ["server jobs resume cleanup", t.t("cli.server.jobs.resume.help.run_cleanup_schedule_again", "Run cleanup on schedule again")]
382
+ ]);
383
+ },
254
384
  valueRequired: true,
255
385
  parameters: [serverParam],
256
386
  processCommand: async (cmd, context) => {
@@ -261,6 +391,21 @@ var CliJobsCommandProcessor = class {
261
391
  {
262
392
  command: "stop",
263
393
  description: "Stop a job and cancel current execution if running",
394
+ writeDescription: (context) => {
395
+ const t = context.translator;
396
+ writeWrapped(
397
+ context,
398
+ t.t("cli.server.jobs.stop.help.take_job_off_schedule", "Take a job off its schedule and cancel the run in progress, if there is one.")
399
+ );
400
+ context.writer.writeln();
401
+ writeUsage(context, [
402
+ ["server jobs stop <name|id>", ""]
403
+ ]);
404
+ context.writer.writeln();
405
+ writeExamples(context, [
406
+ ["server jobs stop cleanup", t.t("cli.server.jobs.stop.help.stop_cleanup_completely", "Stop cleanup completely")]
407
+ ]);
408
+ },
264
409
  valueRequired: true,
265
410
  parameters: [serverParam],
266
411
  processCommand: async (cmd, context) => {
@@ -271,6 +416,21 @@ var CliJobsCommandProcessor = class {
271
416
  {
272
417
  command: "cancel",
273
418
  description: "Cancel the current execution of a job",
419
+ writeDescription: (context) => {
420
+ const t = context.translator;
421
+ writeWrapped(
422
+ context,
423
+ t.t("cli.server.jobs.cancel.help.cancel_run_progress_job", "Cancel the run in progress of a job. The job stays on its schedule and runs again at the next time.")
424
+ );
425
+ context.writer.writeln();
426
+ writeUsage(context, [
427
+ ["server jobs cancel <name|id>", ""]
428
+ ]);
429
+ context.writer.writeln();
430
+ writeExamples(context, [
431
+ ["server jobs cancel cleanup", t.t("cli.server.jobs.cancel.help.abort_current_cleanup_run", "Abort the current cleanup run")]
432
+ ]);
433
+ },
274
434
  valueRequired: true,
275
435
  parameters: [serverParam],
276
436
  processCommand: async (cmd, context) => {
@@ -281,6 +441,23 @@ var CliJobsCommandProcessor = class {
281
441
  {
282
442
  command: "history",
283
443
  description: "Show execution history for a job",
444
+ writeDescription: (context) => {
445
+ const t = context.translator;
446
+ writeWrapped(
447
+ context,
448
+ t.t("cli.server.jobs.history.help.list_past_runs_job", "List the past runs of a job: status, start time, duration, retry attempt and the start of the error message. Use the short ID with logs --exec to read the output of one run.")
449
+ );
450
+ context.writer.writeln();
451
+ writeUsage(context, [
452
+ ["server jobs history <name|id>", t.t("cli.server.jobs.history.help.last_20_runs", "The last 20 runs")],
453
+ ["server jobs history <name|id> --limit <n> --offset <n>", t.t("cli.server.jobs.history.help.page_through_older_runs", "Page through older runs")]
454
+ ]);
455
+ context.writer.writeln();
456
+ writeExamples(context, [
457
+ ["server jobs history cleanup --status failed", t.t("cli.server.jobs.history.help.only_failed_runs", "Only failed runs")],
458
+ ["server jobs history cleanup --limit 5", t.t("cli.server.jobs.history.help.last_5_runs", "The last 5 runs")]
459
+ ]);
460
+ },
284
461
  valueRequired: true,
285
462
  parameters: [
286
463
  serverParam,
@@ -313,6 +490,22 @@ var CliJobsCommandProcessor = class {
313
490
  {
314
491
  command: "logs",
315
492
  description: "Show logs from the latest or a specific execution",
493
+ writeDescription: (context) => {
494
+ const t = context.translator;
495
+ writeWrapped(
496
+ context,
497
+ t.t("cli.server.jobs.logs.help.print_log_entries_latest", "Print the log entries of the latest run of a job, or of one run picked by its execution ID (from history).")
498
+ );
499
+ context.writer.writeln();
500
+ writeUsage(context, [
501
+ ["server jobs logs <name|id>", t.t("cli.server.jobs.logs.help.logs_latest_run", "Logs of the latest run")],
502
+ ["server jobs logs <name|id> --exec <id>", t.t("cli.server.jobs.logs.help.logs_one_run", "Logs of one run")]
503
+ ]);
504
+ context.writer.writeln();
505
+ writeExamples(context, [
506
+ ["server jobs logs cleanup", t.t("cli.server.jobs.logs.help.what_last_cleanup_run", "What the last cleanup run printed")]
507
+ ]);
508
+ },
316
509
  valueRequired: true,
317
510
  parameters: [
318
511
  serverParam,
@@ -331,6 +524,21 @@ var CliJobsCommandProcessor = class {
331
524
  {
332
525
  command: "edit",
333
526
  description: "Interactively edit job settings",
527
+ writeDescription: (context) => {
528
+ const t = context.translator;
529
+ writeWrapped(
530
+ context,
531
+ t.t("cli.server.jobs.edit.help.change_job_step_step", "Change a job step by step: description, group, cron schedule or interval, max retries, timeout and overlap policy (skip, queue or cancel). Press Enter to keep a value and Ctrl+C or Escape to cancel; nothing changes until you confirm. A job on several servers needs --server.")
532
+ );
533
+ context.writer.writeln();
534
+ writeUsage(context, [
535
+ ["server jobs edit <name|id>", ""]
536
+ ]);
537
+ context.writer.writeln();
538
+ writeExamples(context, [
539
+ ["server jobs edit cleanup", t.t("cli.server.jobs.edit.help.edit_cleanup_server_has", "Edit cleanup on the server that has it")]
540
+ ]);
541
+ },
334
542
  valueRequired: true,
335
543
  parameters: [serverParam],
336
544
  processCommand: async (cmd, context) => {
@@ -341,6 +549,21 @@ var CliJobsCommandProcessor = class {
341
549
  {
342
550
  command: "watch",
343
551
  description: "Live view of job events via WebSocket",
552
+ writeDescription: (context) => {
553
+ const t = context.translator;
554
+ writeWrapped(
555
+ context,
556
+ t.t("cli.server.jobs.watch.help.follow_job_events_they", "Follow job events as they happen (started, completed, failed and so on) on every connected server, one line per event. Press Ctrl+C to stop.")
557
+ );
558
+ context.writer.writeln();
559
+ writeUsage(context, [
560
+ ["server jobs watch", t.t("cli.server.jobs.watch.help.events_all_connected_servers", "Events from all connected servers")]
561
+ ]);
562
+ context.writer.writeln();
563
+ writeExamples(context, [
564
+ ["server jobs watch --server node", t.t("cli.server.jobs.watch.help.only_events_node", "Only events from node")]
565
+ ]);
566
+ },
344
567
  parameters: [serverParam],
345
568
  processCommand: async (cmd, context) => {
346
569
  await this.handleWatch(cmd, context);
@@ -414,9 +637,9 @@ var CliJobsCommandProcessor = class {
414
637
  }
415
638
  // ── Info ──────────────────────────────────────────────────────────
416
639
  async handleInfo(cmd, context) {
417
- const nameOrId = (cmd.value ?? "").trim();
640
+ const nameOrId = joinShellWords(cmd.value ?? "");
418
641
  if (!nameOrId) {
419
- context.writer.writeError("Usage: jobs info <id|name>");
642
+ context.writer.writeError("Usage: server jobs info <id|name>");
420
643
  return;
421
644
  }
422
645
  const services = getServices(context, cmd.args);
@@ -424,74 +647,53 @@ var CliJobsCommandProcessor = class {
424
647
  context.writer.writeInfo("No connected servers.");
425
648
  return;
426
649
  }
427
- for (const { name, service } of services) {
428
- try {
429
- const job = await resolveJobId(service, nameOrId);
430
- if (!job) {
431
- if (services.length === 1) {
432
- context.writer.writeError(
433
- `Job "${nameOrId}" not found on server ${name}.`
434
- );
435
- }
436
- continue;
437
- }
438
- if (services.length > 1) {
439
- context.writer.writeln(
440
- context.writer.wrapInColor(
441
- `Server: ${name}`,
442
- CliForegroundColor.Cyan
443
- )
444
- );
445
- }
446
- const lines = [
447
- ["ID", job.id],
448
- ["Name", job.name],
449
- ["Description", job.description],
450
- ["Group", job.group ?? "-"],
451
- ["Status", statusColor(job.status, context.writer)],
452
- [
453
- "Schedule",
454
- job.schedule ?? (job.interval ? `every ${job.interval}ms` : "-")
455
- ],
456
- ["Max Retries", String(job.maxRetries)],
457
- [
458
- "Timeout",
459
- job.timeout ? `${job.timeout}ms` : "none"
460
- ],
461
- ["Overlap Policy", job.overlapPolicy],
462
- [
463
- "Current Execution",
464
- job.currentExecutionId ?? "none"
465
- ],
466
- ["Next Run", formatRelativeTime(job.nextRunAt)],
467
- ["Last Run", formatRelativeTime(job.lastRunAt)],
468
- [
469
- "Last Run Status",
470
- job.lastRunStatus ? statusColor(job.lastRunStatus, context.writer) : "-"
471
- ],
472
- ["Last Run Duration", formatDuration(job.lastRunDuration)]
473
- ];
474
- for (const [label, value] of lines) {
475
- context.writer.writeln(
476
- ` ${context.writer.wrapInColor(
477
- label.padEnd(20),
478
- CliForegroundColor.Yellow
479
- )} ${value}`
480
- );
481
- }
482
- return;
483
- } catch (e) {
484
- context.writer.writeError(
485
- `Error on server ${name}: ${e.message}`
650
+ const matches = await findJobOnServers(context, services, nameOrId);
651
+ for (const { name, job } of matches) {
652
+ if (services.length > 1) {
653
+ writeServerHeader(context, name);
654
+ }
655
+ const lines = [
656
+ ["ID", job.id],
657
+ ["Name", job.name],
658
+ ["Description", job.description],
659
+ ["Group", job.group ?? "-"],
660
+ ["Status", statusColor(job.status, context.writer)],
661
+ [
662
+ "Schedule",
663
+ job.schedule ?? (job.interval ? `every ${job.interval}ms` : "-")
664
+ ],
665
+ ["Max Retries", String(job.maxRetries)],
666
+ ["Timeout", job.timeout ? `${job.timeout}ms` : "none"],
667
+ ["Overlap Policy", job.overlapPolicy],
668
+ ["Current Execution", job.currentExecutionId ?? "none"],
669
+ ["Next Run", formatRelativeTime(job.nextRunAt)],
670
+ ["Last Run", formatRelativeTime(job.lastRunAt)],
671
+ [
672
+ "Last Run Status",
673
+ job.lastRunStatus ? statusColor(job.lastRunStatus, context.writer) : "-"
674
+ ],
675
+ ["Last Run Duration", formatDuration(job.lastRunDuration)]
676
+ ];
677
+ for (const [label, value] of lines) {
678
+ context.writer.writeln(
679
+ ` ${context.writer.wrapInColor(
680
+ label.padEnd(20),
681
+ CliForegroundColor.Yellow
682
+ )} ${value}`
486
683
  );
487
684
  }
488
685
  }
489
686
  }
490
687
  // ── Action (trigger/pause/resume/stop/cancel) ────────────────────
688
+ /**
689
+ * Without --server the action applies to every connected server that has
690
+ * the job, each reported on its own line. When several servers have it,
691
+ * a note says so (and how to target one).
692
+ */
491
693
  async handleAction(cmd, context, action) {
492
- const nameOrId = (cmd.value ?? "").trim();
694
+ const nameOrId = joinShellWords(cmd.value ?? "");
493
695
  if (!nameOrId) {
494
- context.writer.writeError(`Usage: jobs ${action} <id|name>`);
696
+ context.writer.writeError(`Usage: server jobs ${action} <id|name>`);
495
697
  return;
496
698
  }
497
699
  const services = getServices(context, cmd.args);
@@ -499,17 +701,14 @@ var CliJobsCommandProcessor = class {
499
701
  context.writer.writeInfo("No connected servers.");
500
702
  return;
501
703
  }
502
- for (const { name, service } of services) {
704
+ const matches = await findJobOnServers(context, services, nameOrId);
705
+ if (matches.length > 1) {
706
+ context.writer.writeInfo(
707
+ `Job "${nameOrId}" is on ${matches.length} servers (${matches.map((m) => m.name).join(", ")}); applying ${action} to each. Use --server <name> to target one.`
708
+ );
709
+ }
710
+ for (const { name, service, job } of matches) {
503
711
  try {
504
- const job = await resolveJobId(service, nameOrId);
505
- if (!job) {
506
- if (services.length === 1) {
507
- context.writer.writeError(
508
- `Job "${nameOrId}" not found on server ${name}.`
509
- );
510
- }
511
- continue;
512
- }
513
712
  switch (action) {
514
713
  case "trigger":
515
714
  await service.triggerJob(job.id);
@@ -529,9 +728,8 @@ var CliJobsCommandProcessor = class {
529
728
  }
530
729
  const serverSuffix = services.length > 1 ? ` on server ${name}` : "";
531
730
  context.writer.writeSuccess(
532
- `Job "${job.name}" ${action}${action.endsWith("e") ? "d" : "ed"}${serverSuffix}.`
731
+ `Job "${job.name}" ${PAST_TENSE[action] ?? `${action}ed`}${serverSuffix}.`
533
732
  );
534
- return;
535
733
  } catch (e) {
536
734
  context.writer.writeError(
537
735
  `Failed to ${action} job on ${name}: ${e.message}`
@@ -541,9 +739,9 @@ var CliJobsCommandProcessor = class {
541
739
  }
542
740
  // ── History ──────────────────────────────────────────────────────
543
741
  async handleHistory(cmd, context) {
544
- const nameOrId = (cmd.value ?? "").trim();
742
+ const nameOrId = joinShellWords(cmd.value ?? "");
545
743
  if (!nameOrId) {
546
- context.writer.writeError("Usage: jobs history <id|name>");
744
+ context.writer.writeError("Usage: server jobs history <id|name>");
547
745
  return;
548
746
  }
549
747
  const services = getServices(context, cmd.args);
@@ -554,24 +752,11 @@ var CliJobsCommandProcessor = class {
554
752
  const limit = cmd.args["limit"] ?? 20;
555
753
  const offset = cmd.args["offset"] ?? 0;
556
754
  const status = cmd.args["status"];
557
- for (const { name, service } of services) {
755
+ const matches = await findJobOnServers(context, services, nameOrId);
756
+ for (const { name, service, job } of matches) {
558
757
  try {
559
- const job = await resolveJobId(service, nameOrId);
560
- if (!job) {
561
- if (services.length === 1) {
562
- context.writer.writeError(
563
- `Job "${nameOrId}" not found on server ${name}.`
564
- );
565
- }
566
- continue;
567
- }
568
758
  if (services.length > 1) {
569
- context.writer.writeln(
570
- context.writer.wrapInColor(
571
- `Server: ${name}`,
572
- CliForegroundColor.Cyan
573
- )
574
- );
759
+ writeServerHeader(context, name);
575
760
  }
576
761
  const history = await service.getHistory(job.id, {
577
762
  limit,
@@ -582,7 +767,7 @@ var CliJobsCommandProcessor = class {
582
767
  context.writer.writeInfo(
583
768
  `No execution history for "${job.name}".`
584
769
  );
585
- return;
770
+ continue;
586
771
  }
587
772
  context.writer.writeln(
588
773
  `Showing ${history.items.length} of ${history.total} executions for "${job.name}":`
@@ -604,7 +789,6 @@ var CliJobsCommandProcessor = class {
604
789
  exec.error ? exec.error.substring(0, 40) : "-"
605
790
  ]);
606
791
  context.writer.writeTable(headers, rows);
607
- return;
608
792
  } catch (e) {
609
793
  context.writer.writeError(
610
794
  `Error on server ${name}: ${e.message}`
@@ -614,9 +798,9 @@ var CliJobsCommandProcessor = class {
614
798
  }
615
799
  // ── Logs ─────────────────────────────────────────────────────────
616
800
  async handleLogs(cmd, context) {
617
- const nameOrId = (cmd.value ?? "").trim();
801
+ const nameOrId = joinShellWords(cmd.value ?? "");
618
802
  if (!nameOrId) {
619
- context.writer.writeError("Usage: jobs logs <id|name>");
803
+ context.writer.writeError("Usage: server jobs logs <id|name>");
620
804
  return;
621
805
  }
622
806
  const services = getServices(context, cmd.args);
@@ -625,16 +809,11 @@ var CliJobsCommandProcessor = class {
625
809
  return;
626
810
  }
627
811
  const execId = cmd.args["exec"];
628
- for (const { name, service } of services) {
812
+ const matches = await findJobOnServers(context, services, nameOrId);
813
+ for (const { name, service, job } of matches) {
629
814
  try {
630
- const job = await resolveJobId(service, nameOrId);
631
- if (!job) {
632
- if (services.length === 1) {
633
- context.writer.writeError(
634
- `Job "${nameOrId}" not found on server ${name}.`
635
- );
636
- }
637
- continue;
815
+ if (services.length > 1) {
816
+ writeServerHeader(context, name);
638
817
  }
639
818
  let execution;
640
819
  if (execId) {
@@ -647,27 +826,19 @@ var CliJobsCommandProcessor = class {
647
826
  context.writer.writeInfo(
648
827
  `No executions found for "${job.name}".`
649
828
  );
650
- return;
829
+ continue;
651
830
  }
652
831
  execution = await service.getExecution(
653
832
  job.id,
654
833
  history.items[0].id
655
834
  );
656
835
  }
657
- if (services.length > 1) {
658
- context.writer.writeln(
659
- context.writer.wrapInColor(
660
- `Server: ${name}`,
661
- CliForegroundColor.Cyan
662
- )
663
- );
664
- }
665
836
  context.writer.writeln(
666
837
  `Logs for "${job.name}" execution ${execution.id.substring(0, 8)} (${statusColor(execution.status, context.writer)}):`
667
838
  );
668
839
  if (!execution.logs || execution.logs.length === 0) {
669
840
  context.writer.writeInfo(" No log entries.");
670
- return;
841
+ continue;
671
842
  }
672
843
  for (const entry of execution.logs) {
673
844
  const ts = new Date(entry.timestamp).toLocaleTimeString();
@@ -679,7 +850,6 @@ var CliJobsCommandProcessor = class {
679
850
  ` ${context.writer.wrapInColor(ts, CliForegroundColor.White)} ${level} ${entry.message}`
680
851
  );
681
852
  }
682
- return;
683
853
  } catch (e) {
684
854
  context.writer.writeError(
685
855
  `Error on server ${name}: ${e.message}`
@@ -688,10 +858,15 @@ var CliJobsCommandProcessor = class {
688
858
  }
689
859
  }
690
860
  // ── Edit ─────────────────────────────────────────────────────────
861
+ /**
862
+ * Prompt for each setting; Enter keeps the current value. Ctrl+C or
863
+ * Escape at any prompt (the reader resolves null) cancels the whole edit
864
+ * and nothing is sent. A job on several servers needs --server.
865
+ */
691
866
  async handleEdit(cmd, context) {
692
- const nameOrId = (cmd.value ?? "").trim();
867
+ const nameOrId = joinShellWords(cmd.value ?? "");
693
868
  if (!nameOrId) {
694
- context.writer.writeError("Usage: jobs edit <id|name>");
869
+ context.writer.writeError("Usage: server jobs edit <id|name>");
695
870
  return;
696
871
  }
697
872
  const services = getServices(context, cmd.args);
@@ -699,118 +874,113 @@ var CliJobsCommandProcessor = class {
699
874
  context.writer.writeInfo("No connected servers.");
700
875
  return;
701
876
  }
702
- for (const { name, service } of services) {
703
- try {
704
- const job = await resolveJobId(service, nameOrId);
705
- if (!job) {
706
- if (services.length === 1) {
707
- context.writer.writeError(
708
- `Job "${nameOrId}" not found on server ${name}.`
709
- );
710
- }
711
- continue;
712
- }
713
- context.writer.writeln(
714
- `Editing job "${context.writer.wrapInColor(job.name, CliForegroundColor.Cyan)}" on server ${name}`
715
- );
716
- context.writer.writeln(
717
- "Press Enter to keep current value, or type a new value."
718
- );
719
- context.writer.writeln("");
720
- const update = {};
721
- const newDesc = await context.reader.readLine(
722
- `Description [${job.description}]: `
723
- );
724
- if (newDesc == null) {
725
- context.writer.writeInfo("Edit cancelled.");
726
- return;
727
- }
728
- if (newDesc.trim()) {
729
- update.description = newDesc.trim();
730
- }
731
- const newGroup = await context.reader.readLine(
732
- `Group [${job.group ?? "none"}]: `
733
- );
734
- if (newGroup == null) {
735
- context.writer.writeInfo("Edit cancelled.");
736
- return;
737
- }
738
- if (newGroup.trim()) {
739
- update.group = newGroup.trim();
740
- }
741
- const currentSchedule = job.schedule ?? (job.interval ? `interval: ${job.interval}ms` : "none");
742
- const newSchedule = await context.reader.readLine(
743
- `Cron schedule [${currentSchedule}]: `
744
- );
745
- if (newSchedule == null) {
746
- context.writer.writeInfo("Edit cancelled.");
747
- return;
748
- }
749
- if (newSchedule.trim()) {
750
- update.schedule = newSchedule.trim();
751
- }
752
- if (!newSchedule.trim()) {
753
- const newInterval = await context.reader.readLine(
754
- `Interval (e.g. 30s, 5m) [${job.interval ? `${job.interval}ms` : "none"}]: `
755
- );
756
- if (newInterval != null && newInterval.trim()) {
757
- update.interval = newInterval.trim();
758
- }
759
- }
760
- const newRetries = await context.reader.readLine(
761
- `Max retries [${job.maxRetries}]: `
762
- );
763
- if (newRetries != null && newRetries.trim()) {
764
- const parsed = parseInt(newRetries.trim(), 10);
765
- if (!isNaN(parsed)) {
766
- update.maxRetries = parsed;
767
- }
768
- }
769
- const newTimeout = await context.reader.readLine(
770
- `Timeout (e.g. 5m, 1h) [${job.timeout ? `${job.timeout}ms` : "none"}]: `
771
- );
772
- if (newTimeout != null && newTimeout.trim()) {
773
- update.timeout = newTimeout.trim();
774
- }
775
- const newOverlap = await context.reader.readLine(
776
- `Overlap policy (skip/queue/cancel) [${job.overlapPolicy}]: `
777
- );
778
- if (newOverlap != null && newOverlap.trim() && ["skip", "queue", "cancel"].includes(
779
- newOverlap.trim().toLowerCase()
780
- )) {
781
- update.overlapPolicy = newOverlap.trim().toLowerCase();
782
- }
783
- if (Object.keys(update).length === 0) {
784
- context.writer.writeInfo("No changes made.");
785
- return;
786
- }
787
- const confirm = await context.reader.readConfirm(
788
- "Apply changes?"
877
+ const matches = await findJobOnServers(context, services, nameOrId);
878
+ if (matches.length === 0) {
879
+ return;
880
+ }
881
+ if (matches.length > 1) {
882
+ context.writer.writeError(
883
+ `Job "${nameOrId}" is on ${matches.length} servers (${matches.map((m) => m.name).join(", ")}). Use --server <name> to choose the one to edit.`
884
+ );
885
+ return;
886
+ }
887
+ const { name, service, job } = matches[0];
888
+ const cancelled = () => {
889
+ context.writer.writeInfo("Edit cancelled.");
890
+ };
891
+ const aborted = () => !!context.signal?.aborted;
892
+ const ask = async (prompt) => {
893
+ if (aborted()) return null;
894
+ const answer = await context.reader.readLine(prompt);
895
+ return answer == null || aborted() ? null : answer.trim();
896
+ };
897
+ try {
898
+ context.writer.writeln(
899
+ `Editing job "${context.writer.wrapInColor(job.name, CliForegroundColor.Cyan)}" on server ${name}`
900
+ );
901
+ context.writer.writeln(
902
+ "Press Enter to keep current value, or type a new value. Ctrl+C cancels."
903
+ );
904
+ context.writer.writeln("");
905
+ const update = {};
906
+ const newDesc = await ask(`Description [${job.description}]: `);
907
+ if (newDesc == null) return cancelled();
908
+ if (newDesc) update.description = newDesc;
909
+ const newGroup = await ask(`Group [${job.group ?? "none"}]: `);
910
+ if (newGroup == null) return cancelled();
911
+ if (newGroup) update.group = newGroup;
912
+ const currentSchedule = job.schedule ?? (job.interval ? `interval: ${job.interval}ms` : "none");
913
+ const newSchedule = await ask(
914
+ `Cron schedule [${currentSchedule}]: `
915
+ );
916
+ if (newSchedule == null) return cancelled();
917
+ if (newSchedule) update.schedule = newSchedule;
918
+ if (!newSchedule) {
919
+ const newInterval = await ask(
920
+ `Interval (e.g. 30s, 5m) [${job.interval ? `${job.interval}ms` : "none"}]: `
789
921
  );
790
- if (!confirm) {
791
- context.writer.writeInfo("Edit cancelled.");
792
- return;
922
+ if (newInterval == null) return cancelled();
923
+ if (newInterval) update.interval = newInterval;
924
+ }
925
+ const newRetries = await ask(`Max retries [${job.maxRetries}]: `);
926
+ if (newRetries == null) return cancelled();
927
+ if (newRetries) {
928
+ const parsed = parseInt(newRetries, 10);
929
+ if (!isNaN(parsed)) {
930
+ update.maxRetries = parsed;
793
931
  }
794
- await service.updateJob(job.id, update);
795
- context.writer.writeSuccess(
796
- `Job "${job.name}" updated successfully.`
797
- );
932
+ }
933
+ const newTimeout = await ask(
934
+ `Timeout (e.g. 5m, 1h) [${job.timeout ? `${job.timeout}ms` : "none"}]: `
935
+ );
936
+ if (newTimeout == null) return cancelled();
937
+ if (newTimeout) update.timeout = newTimeout;
938
+ const newOverlap = await ask(
939
+ `Overlap policy (skip/queue/cancel) [${job.overlapPolicy}]: `
940
+ );
941
+ if (newOverlap == null) return cancelled();
942
+ if (newOverlap && ["skip", "queue", "cancel"].includes(newOverlap.toLowerCase())) {
943
+ update.overlapPolicy = newOverlap.toLowerCase();
944
+ }
945
+ if (Object.keys(update).length === 0) {
946
+ context.writer.writeInfo("No changes made.");
798
947
  return;
799
- } catch (e) {
800
- context.writer.writeError(
801
- `Error on server ${name}: ${e.message}`
802
- );
803
948
  }
949
+ const confirm = aborted() ? null : await context.reader.readConfirm("Apply changes?");
950
+ if (!confirm || aborted()) return cancelled();
951
+ await service.updateJob(job.id, update);
952
+ context.writer.writeSuccess(
953
+ `Job "${job.name}" updated successfully.`
954
+ );
955
+ } catch (e) {
956
+ context.writer.writeError(`Error on server ${name}: ${e.message}`);
804
957
  }
805
958
  }
806
959
  // ── Watch ────────────────────────────────────────────────────────
960
+ /**
961
+ * Stream job events until Ctrl+C (context.onAbort / context.signal) or
962
+ * until every watched server has closed its connection. On exit the
963
+ * sockets are closed and every subscription and listener is removed.
964
+ */
807
965
  async handleWatch(cmd, context) {
808
- const manager = context.services.get("cli-server-manager");
966
+ const manager = context.services.getRequired("cli-server-manager");
809
967
  if (!manager || !manager.connections) {
810
968
  context.writer.writeInfo("No connected servers.");
811
969
  return;
812
970
  }
813
971
  const targetServer = cmd.args["server"];
972
+ const targets = [];
973
+ for (const [serverName, connection] of manager.connections) {
974
+ if (targetServer && serverName !== targetServer) continue;
975
+ if (!connection.connected) continue;
976
+ targets.push([serverName, connection]);
977
+ }
978
+ if (targets.length === 0) {
979
+ context.writer.writeInfo(
980
+ targetServer ? `Server "${targetServer}" is not connected.` : "No connected servers."
981
+ );
982
+ return;
983
+ }
814
984
  context.writer.writeln(
815
985
  context.writer.wrapInColor(
816
986
  "Watching job events... (press Ctrl+C to stop)",
@@ -818,33 +988,60 @@ var CliJobsCommandProcessor = class {
818
988
  )
819
989
  );
820
990
  context.writer.writeln("");
821
- const sockets = [];
822
- const abortHandler = () => {
823
- for (const ws of sockets) {
824
- try {
825
- ws.close();
826
- } catch {
827
- }
991
+ const open = /* @__PURE__ */ new Set();
992
+ let stopped = false;
993
+ let reason = "aborted";
994
+ let resolveDone;
995
+ const done = new Promise((resolve) => resolveDone = resolve);
996
+ const closeSocket = (ws) => {
997
+ ws.onmessage = null;
998
+ ws.onerror = null;
999
+ ws.onclose = null;
1000
+ try {
1001
+ ws.close();
1002
+ } catch {
828
1003
  }
829
1004
  };
830
- context.onAbort.subscribe(abortHandler);
831
- for (const [serverName, connection] of manager.connections) {
832
- if (targetServer && serverName !== targetServer) continue;
833
- if (!connection.connected) continue;
1005
+ const onAbortSignal = () => stop("aborted");
1006
+ const abortSub = context.onAbort?.subscribe(onAbortSignal);
1007
+ context.signal?.addEventListener("abort", onAbortSignal);
1008
+ function stop(why) {
1009
+ if (stopped) return;
1010
+ stopped = true;
1011
+ reason = why;
1012
+ abortSub?.unsubscribe();
1013
+ context.signal?.removeEventListener("abort", onAbortSignal);
1014
+ for (const ws of open) closeSocket(ws);
1015
+ open.clear();
1016
+ resolveDone();
1017
+ }
1018
+ if (context.signal?.aborted) {
1019
+ stop("aborted");
1020
+ }
1021
+ const multiServer = targets.length > 1;
1022
+ for (const [serverName, connection] of targets) {
1023
+ if (stopped) break;
834
1024
  const config = connection.config;
835
1025
  const baseUrl = config.url.endsWith("/") ? config.url.slice(0, -1) : config.url;
836
- const wsUrl = baseUrl.replace(/^https:/, "wss:").replace(/^http:/, "ws:") + "/ws/v1/qcli/events";
837
1026
  try {
1027
+ const wsUrl = await resolveServerWebSocketUrl(
1028
+ context.services,
1029
+ serverName,
1030
+ baseUrl.replace(/^https:/, "wss:").replace(/^http:/, "ws:") + "/ws/v1/qcli/events",
1031
+ config.headers
1032
+ );
1033
+ if (stopped) break;
838
1034
  const ws = new WebSocket(wsUrl);
839
- sockets.push(ws);
1035
+ open.add(ws);
840
1036
  ws.onmessage = (event) => {
1037
+ if (stopped) return;
841
1038
  try {
842
1039
  const data = JSON.parse(event.data);
843
1040
  if (!data.type || !data.type.startsWith("job:")) {
844
1041
  return;
845
1042
  }
846
1043
  const ts = (/* @__PURE__ */ new Date()).toLocaleTimeString();
847
- const serverPrefix = manager.connections.size > 1 ? context.writer.wrapInColor(
1044
+ const serverPrefix = multiServer ? context.writer.wrapInColor(
848
1045
  `[${serverName}] `,
849
1046
  CliForegroundColor.Cyan
850
1047
  ) : "";
@@ -872,11 +1069,19 @@ var CliJobsCommandProcessor = class {
872
1069
  }
873
1070
  };
874
1071
  ws.onerror = () => {
1072
+ if (stopped) return;
875
1073
  context.writer.writeError(
876
1074
  `WebSocket error on server ${serverName}`
877
1075
  );
878
1076
  };
879
1077
  ws.onclose = () => {
1078
+ if (stopped || !open.delete(ws)) return;
1079
+ context.writer.writeWarning(
1080
+ `Connection to server ${serverName} closed.`
1081
+ );
1082
+ if (open.size === 0) {
1083
+ stop("closed");
1084
+ }
880
1085
  };
881
1086
  } catch (e) {
882
1087
  context.writer.writeError(
@@ -884,24 +1089,23 @@ var CliJobsCommandProcessor = class {
884
1089
  );
885
1090
  }
886
1091
  }
887
- await new Promise((resolve) => {
888
- const sub = context.onAbort.subscribe(() => {
889
- sub.unsubscribe();
890
- resolve();
891
- });
892
- if (context.signal) {
893
- context.signal.addEventListener("abort", () => {
894
- sub.unsubscribe();
895
- resolve();
896
- });
897
- }
898
- });
1092
+ if (!stopped && open.size === 0) {
1093
+ stop("closed");
1094
+ }
1095
+ await done;
899
1096
  context.writer.writeln("");
900
- context.writer.writeInfo("Stopped watching job events.");
1097
+ context.writer.writeInfo(
1098
+ reason === "closed" ? "Stopped watching job events: no server connection left." : "Stopped watching job events."
1099
+ );
901
1100
  }
902
1101
  };
903
-
904
- // src/lib/completion/cli-job-name-completion-provider.ts
1102
+ var PAST_TENSE = {
1103
+ trigger: "triggered",
1104
+ pause: "paused",
1105
+ resume: "resumed",
1106
+ stop: "stopped",
1107
+ cancel: "cancelled"
1108
+ };
905
1109
  var JOB_NAME_SUBCOMMANDS = /* @__PURE__ */ new Set([
906
1110
  "info",
907
1111
  "trigger",
@@ -941,16 +1145,16 @@ var CliJobNameCompletionProvider = class {
941
1145
  }
942
1146
  const lowerPrefix = token.toLowerCase();
943
1147
  try {
944
- const manager = this.services.get("cli-server-manager");
1148
+ const manager = this.services.getRequired("cli-server-manager");
945
1149
  if (!manager?.connections) {
946
1150
  return [];
947
1151
  }
948
1152
  const names = [];
949
- for (const [, connection] of manager.connections) {
1153
+ for (const [name, connection] of manager.connections) {
950
1154
  if (!connection.connected) continue;
951
1155
  const config = connection.config;
952
1156
  const baseUrl = config.url.endsWith("/") ? config.url.slice(0, -1) : config.url;
953
- const headers = config.headers ?? {};
1157
+ const headers = resolveServerHeaders(this.services, name, config.headers);
954
1158
  const service = new CliJobsService(baseUrl, headers);
955
1159
  try {
956
1160
  const jobs = await service.listJobs();
@@ -970,7 +1174,7 @@ var CliJobNameCompletionProvider = class {
970
1174
  };
971
1175
 
972
1176
  // src/lib/version.ts
973
- var LIBRARY_VERSION = "2.0.0-beta.9";
1177
+ var LIBRARY_VERSION = "2.0.0-rc.1";
974
1178
  var API_VERSION = 2;
975
1179
  var completionProvider = new CliJobNameCompletionProvider();
976
1180
  var jobsModule = {