@rjfan/dsh-presence 0.19.0 → 0.20.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/src/pool.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The pool the plugin owns: one live connection per machine, plus the
2
2
  // terminals that belong to it.
3
3
 
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { listHosts, readSshConfig } from './core/ssh-config.mjs';
5
6
  import { PresenceConnection } from './core/connection.mjs';
6
7
 
@@ -9,12 +10,12 @@ const MAX_TERMINAL_BUFFER = 256 * 1024;
9
10
  // started: give the wrapper a moment, then check the process group.
10
11
  const JOB_EARLY_CHECK_MS = 800;
11
12
  const JOB_EARLY_TAIL_LINES = 20;
12
- // Finished jobs pile up in the local registry for the life of the app. Prune
13
- // them at most once a minute from `listJobs`, keeping the newest entries and
14
- // anything that finished in the last six hours.
13
+ // Finished jobs pile up in the local registry and in the JSON store. Prune at
14
+ // most once a minute from `listJobs`; the 0.20 store keeps a month of finished
15
+ // jobs (a run is worth coming back to after a restart), bounded to 500 rows.
15
16
  const JOB_PRUNE_INTERVAL_MS = 60_000;
16
- const JOB_PRUNE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
17
- const JOB_PRUNE_MAX_ENTRIES = 100;
17
+ const JOB_PRUNE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
18
+ const JOB_PRUNE_MAX_ENTRIES = 500;
18
19
  // A transient connect failure is retried twice, with these gaps, and a dead
19
20
  // cached connection is dropped before each retry.
20
21
  const CONNECT_RETRY_BACKOFF_MS = [200, 600];
@@ -60,6 +61,7 @@ export class PresencePool {
60
61
  #terminals = new Map();
61
62
  #tunnels = new Map();
62
63
  #jobs = new Map();
64
+ #jobStore = null;
63
65
  #lastUsed = new Map();
64
66
  #lastPruneAt = 0;
65
67
  #secrets = new Map();
@@ -69,10 +71,24 @@ export class PresencePool {
69
71
  #reaper;
70
72
  #counter = 0;
71
73
 
72
- constructor(settings) {
74
+ /**
75
+ * @param settings - resolved plugin config.
76
+ * @param options.jobStore - optional persistent job registry (`JobStore`, or
77
+ * any object with `list/upsert/remove/prune`). It is captured once and
78
+ * never replaced, so persistence cannot be attached halfway through a run.
79
+ */
80
+ constructor(settings, { jobStore = null } = {}) {
73
81
  this.#settings = settings;
82
+ this.#jobStore = jobStore;
83
+ this.#loadPersistedJobs();
74
84
  this.#reaper = setInterval(() => this.#reapIdle(), 60_000);
75
85
  this.#reaper.unref?.();
86
+ // A job started before a restart is `unknown` until the machine is asked;
87
+ // the first successful connection is exactly that chance. Fire-and-forget:
88
+ // a slow probe must never delay or fail `connection()`.
89
+ this.onConnectionEstablished((host) => {
90
+ this.#refreshPersistedJobs(host).catch(() => {});
91
+ });
76
92
  }
77
93
 
78
94
  get settings() {
@@ -373,12 +389,19 @@ export class PresencePool {
373
389
  if (!command) throw new Error('command is required');
374
390
  const connection = await this.connection(host);
375
391
  const started = await connection.startBackground(command, { cwd, logPath });
376
- const id = `j${(this.#counter += 1)}`;
392
+ const id = this.#nextJobId();
393
+ const pid = Number(started.pid);
377
394
  const job = {
378
395
  id,
379
396
  host,
380
- pid: started.pid,
397
+ pid,
398
+ pgid: Number.isFinite(Number(started.pgid)) ? Number(started.pgid) : pid,
381
399
  logPath: started.logPath,
400
+ // The 0.20 factory returns a distinct command-output log when the
401
+ // wrapper exposes one; the current kernel returns only `logPath`, which
402
+ // IS the command's stdout/stderr log. Keep the public field non-null and
403
+ // truthful either way (it equals logPath on today's kernels).
404
+ commandLogPath: started.commandLogPath ?? started.logPath ?? null,
382
405
  statusPath: started.statusPath,
383
406
  command,
384
407
  cwd,
@@ -392,13 +415,29 @@ export class PresencePool {
392
415
  logSize: null,
393
416
  logTail: '',
394
417
  stopped: false,
418
+ persisted: false,
419
+ lastPersistedJson: null,
395
420
  };
396
421
  this.#jobs.set(id, job);
422
+ // Persist before the early-death check: a job that dies instantly must
423
+ // still leave a record, otherwise a restart cannot explain it.
424
+ this.#syncJob(job);
397
425
 
398
426
  await sleep(JOB_EARLY_CHECK_MS);
399
427
  const alive = await this.#groupAlive(connection, job.pid);
400
428
  if (alive !== false) {
401
- return { id, host, pid: job.pid, logPath: job.logPath, statusPath: job.statusPath, label: job.label, sessionId: job.sessionId };
429
+ this.#syncJob(job);
430
+ return {
431
+ id,
432
+ host,
433
+ pid: job.pid,
434
+ pgid: job.pgid,
435
+ logPath: job.logPath,
436
+ commandLogPath: job.commandLogPath,
437
+ statusPath: job.statusPath,
438
+ label: job.label,
439
+ sessionId: job.sessionId,
440
+ };
402
441
  }
403
442
  const status = await connection.backgroundStatus(job.statusPath);
404
443
  const output = await this.#readOutput(connection, job, JOB_EARLY_TAIL_LINES);
@@ -407,11 +446,14 @@ export class PresencePool {
407
446
  job.exitedAt = Date.now();
408
447
  job.logSize = output.logSize;
409
448
  job.logTail = output.logTail;
449
+ this.#syncJob(job);
410
450
  return {
411
451
  id,
412
452
  host,
413
453
  pid: job.pid,
454
+ pgid: job.pgid,
414
455
  logPath: job.logPath,
456
+ commandLogPath: job.commandLogPath,
415
457
  statusPath: job.statusPath,
416
458
  label: job.label,
417
459
  sessionId: job.sessionId,
@@ -458,6 +500,7 @@ export class PresencePool {
458
500
  job.exitedAt ??= Date.now();
459
501
  job.logSize = output.logSize ?? job.logSize;
460
502
  job.logTail = output.logTail;
503
+ this.#syncJob(job);
461
504
  return this.#jobResult(job, {
462
505
  state: 'exited',
463
506
  exitCode: status.exitCode,
@@ -478,6 +521,7 @@ export class PresencePool {
478
521
  job.exitedAt ??= Date.now();
479
522
  job.logSize = output.logSize ?? job.logSize;
480
523
  job.logTail = output.logTail;
524
+ this.#syncJob(job);
481
525
  return this.#jobResult(job, {
482
526
  state: 'exited',
483
527
  exitCode: null,
@@ -520,7 +564,9 @@ export class PresencePool {
520
564
  id: job?.id ?? null,
521
565
  host: target,
522
566
  pid: numeric,
567
+ pgid: job?.pgid ?? numeric,
523
568
  logPath: job?.logPath ?? null,
569
+ commandLogPath: job?.commandLogPath ?? null,
524
570
  stopped: result.stopped,
525
571
  wasAlive: result.wasAlive,
526
572
  reason: result.reason ?? null,
@@ -536,7 +582,9 @@ export class PresencePool {
536
582
  id,
537
583
  host: job.host,
538
584
  pid: job.pid,
585
+ pgid: job.pgid ?? job.pid ?? null,
539
586
  logPath: job.logPath,
587
+ commandLogPath: job.commandLogPath ?? null,
540
588
  stopped: result.stopped,
541
589
  wasAlive: result.wasAlive,
542
590
  reason: result.reason ?? null,
@@ -578,6 +626,7 @@ export class PresencePool {
578
626
  job.exitedAt ??= Date.now();
579
627
  job.exitCode = result.exitCode ?? job.exitCode ?? null;
580
628
  }
629
+ this.#syncJob(job);
581
630
  }
582
631
  return {
583
632
  host,
@@ -593,10 +642,11 @@ export class PresencePool {
593
642
  }
594
643
 
595
644
  /**
596
- * Drop local bookkeeping for jobs that are finished and past their welcome:
597
- * older than `maxAgeMs`, or beyond the newest `maxEntries`. Only EXITED jobs
598
- * are dropped — a running (or still unknown) job keeps its slot no matter
599
- * how old it is, and the detached process on the machine is never touched.
645
+ * Drop bookkeeping for jobs that are finished and past their welcome: older
646
+ * than `maxAgeMs`, or beyond the newest `maxEntries`. Only EXITED jobs are
647
+ * dropped — a running (or still unknown) job keeps its slot no matter how old
648
+ * it is, and the detached process on the machine is never touched. The
649
+ * persisted store is bounded by the same limits.
600
650
  * Returns the ids that were forgotten.
601
651
  */
602
652
  async pruneJobs({ maxAgeMs = JOB_PRUNE_MAX_AGE_MS, maxEntries = JOB_PRUNE_MAX_ENTRIES } = {}) {
@@ -616,15 +666,42 @@ export class PresencePool {
616
666
  if (job.state === 'exited') dropped.add(job.id);
617
667
  }
618
668
  for (const id of dropped) this.#jobs.delete(id);
669
+ if (this.#jobStore) {
670
+ // The store applies the same age/count bounds; a record it drops beyond
671
+ // the local view is forgotten here too, so the two cannot drift apart.
672
+ let pruned = { removed: 0, ids: [] };
673
+ try {
674
+ pruned = this.#jobStore.prune({ maxAgeMs: age, maxEntries: limit }) ?? pruned;
675
+ } catch {
676
+ /* persistence is an optimization; a broken store must not fail a prune */
677
+ }
678
+ for (const id of Array.isArray(pruned.ids) ? pruned.ids : []) {
679
+ this.#jobs.delete(id);
680
+ dropped.add(id);
681
+ }
682
+ // Only records the store's own bound did not already drop need a second
683
+ // write; normally this set is empty (one save, not one per dropped job).
684
+ const prunedIds = new Set(Array.isArray(pruned.ids) ? pruned.ids : []);
685
+ for (const id of [...dropped]) {
686
+ if (prunedIds.has(id)) continue;
687
+ try {
688
+ this.#jobStore.remove(id);
689
+ } catch {
690
+ /* already gone */
691
+ }
692
+ }
693
+ }
619
694
  this.#lastPruneAt = now;
620
695
  return { removed: dropped.size, ids: [...dropped] };
621
696
  }
622
697
 
623
698
  /**
624
- * Jobs this pool started, optionally narrowed to one session, with liveness
625
- * read from the machines connected right now. `state` is 'unknown' when the
626
- * host cannot be asked: a detached job outlives its SSH connection, so no
627
- * answer is not the same as a dead job.
699
+ * Jobs this pool started, plus records inherited from the persisted store,
700
+ * optionally narrowed to one session, with liveness read from the machines
701
+ * connected right now. A record loaded from disk is `unknown` until a pid
702
+ * probe says otherwise: a detached job outlives its SSH connection, so no
703
+ * answer is not the same as a dead job. A record says `persisted: true` when
704
+ * it came from the JSON store rather than from this process.
628
705
  */
629
706
  async listJobs({ sessionId = undefined } = {}) {
630
707
  // Local bookkeeping only, so no round trip; at most once a minute so a
@@ -632,6 +709,9 @@ export class PresencePool {
632
709
  if (Date.now() - this.#lastPruneAt >= JOB_PRUNE_INTERVAL_MS) {
633
710
  await this.pruneJobs().catch(() => {});
634
711
  }
712
+ // A record written by another process (or by a previous run) joins the view
713
+ // as `unknown`; the per-host liveness pass below may resolve it.
714
+ this.#mergePersistedJobs();
635
715
  const jobs = [...this.#jobs.values()].filter((job) => sessionId === undefined || job.sessionId === sessionId);
636
716
  if (jobs.length === 0) return [];
637
717
 
@@ -640,90 +720,228 @@ export class PresencePool {
640
720
  if (!byHost.has(job.host)) byHost.set(job.host, []);
641
721
  byHost.get(job.host).push(job);
642
722
  }
723
+ await Promise.all([...byHost.entries()].map(([host, hostJobs]) => this.#probeHostJobs(host, hostJobs)));
724
+ return jobs.map((job) => this.#jobRow(job));
725
+ }
726
+
727
+ // ------------------------------------------------- job registry internals
643
728
 
729
+ /**
730
+ * Ask one host about a group of jobs: seed its process registry, read the
731
+ * whole process table once and every status file once. Live jobs keep their
732
+ * state; a record loaded from disk is only promoted to `running` when `ps`
733
+ * actually finds its process group.
734
+ */
735
+ async #probeHostJobs(host, hostJobs) {
736
+ const connection = this.#connections.get(host);
737
+ if (!connection || connection.closed) {
738
+ for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
739
+ for (const job of hostJobs) this.#syncJob(job);
740
+ return;
741
+ }
742
+ // A job started before a reconnect is not in this connection's registry;
743
+ // its process group still exists on the machine, so seed it back in.
744
+ for (const job of hostJobs) {
745
+ if (job.stopped || job.state === 'exited' || job.pid === null || job.pid === undefined) continue;
746
+ connection.trackBackground({
747
+ pid: job.pid,
748
+ command: job.command,
749
+ cwd: job.cwd,
750
+ logPath: job.logPath,
751
+ statusPath: job.statusPath,
752
+ startedAt: job.startedAt,
753
+ });
754
+ }
755
+ let running;
756
+ try {
757
+ running = new Set((await connection.listBackground()).map((entry) => entry.pid));
758
+ } catch {
759
+ // Liveness is a convenience; a failed `ps` must not hide the job list.
760
+ running = null;
761
+ }
762
+ if (!running) {
763
+ for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
764
+ for (const job of hostJobs) this.#syncJob(job);
765
+ return;
766
+ }
767
+ // One round trip for every status file on this host: the command may have
768
+ // finished while a child it spawned kept the group alive.
769
+ let statuses = null;
770
+ try {
771
+ statuses = await connection.backgroundStatuses(hostJobs.map((job) => job.statusPath ?? ''));
772
+ } catch {
773
+ // Liveness from `ps` is still worth reporting.
774
+ }
644
775
  await Promise.all(
645
- [...byHost.entries()].map(async ([host, hostJobs]) => {
646
- const connection = this.#connections.get(host);
647
- if (!connection || connection.closed) {
648
- for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
649
- return;
650
- }
651
- // A job started before a reconnect is not in this connection's registry;
652
- // its process group still exists on the machine, so seed it back in.
653
- for (const job of hostJobs) {
654
- if (job.stopped || job.state === 'exited') continue;
655
- connection.trackBackground({
656
- pid: job.pid,
657
- command: job.command,
658
- cwd: job.cwd,
659
- logPath: job.logPath,
660
- statusPath: job.statusPath,
661
- startedAt: job.startedAt,
662
- });
663
- }
664
- let running;
665
- try {
666
- running = new Set((await connection.listBackground()).map((entry) => entry.pid));
667
- } catch {
668
- // Liveness is a convenience; a failed `ps` must not hide the job list.
669
- running = null;
776
+ hostJobs.map(async (job, index) => {
777
+ const status = statuses?.[index] ?? null;
778
+ if (job.stopped) {
779
+ job.state = 'exited';
780
+ job.exitedAt ??= Date.now();
781
+ } else if (status?.found) {
782
+ job.state = 'exited';
783
+ job.exitCode = status.exitCode;
784
+ job.exitedAt ??= Date.now();
785
+ } else if (job.state === 'exited') {
786
+ // Already reported finished; a recycled pgid must not resurrect it.
787
+ job.exitedAt ??= Date.now();
788
+ } else if (job.pid !== null && job.pid !== undefined && running.has(job.pid)) {
789
+ job.state = 'running';
790
+ } else {
791
+ // No wrapper left to write a status: it was killed.
792
+ job.state = 'exited';
793
+ job.exitCode = null;
794
+ job.exitedAt ??= Date.now();
670
795
  }
671
- if (!running) {
672
- for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
673
- return;
674
- }
675
- // One round trip for every status file on this host: the command may
676
- // have finished while a child it spawned kept the group alive.
677
- let statuses = null;
678
- try {
679
- statuses = await connection.backgroundStatuses(hostJobs.map((job) => job.statusPath));
680
- } catch {
681
- // Liveness from `ps` is still worth reporting.
682
- }
683
- await Promise.all(
684
- hostJobs.map(async (job, index) => {
685
- const status = statuses?.[index] ?? null;
686
- if (job.stopped) {
687
- job.state = 'exited';
688
- job.exitedAt ??= Date.now();
689
- } else if (status?.found) {
690
- job.state = 'exited';
691
- job.exitCode = status.exitCode;
692
- job.exitedAt ??= Date.now();
693
- } else if (job.state === 'exited') {
694
- // Already reported finished; a recycled pgid must not resurrect it.
695
- job.exitedAt ??= Date.now();
696
- } else if (running.has(job.pid)) {
697
- job.state = 'running';
698
- } else {
699
- // No wrapper left to write a status: it was killed.
700
- job.state = 'exited';
701
- job.exitCode = null;
702
- job.exitedAt ??= Date.now();
703
- }
704
- const info = await connection.stat(job.logPath).catch(() => null);
705
- if (info?.exists) job.logSize = info.size;
706
- }),
707
- );
796
+ const info = job.logPath ? await connection.stat(job.logPath).catch(() => null) : null;
797
+ if (info?.exists) job.logSize = info.size;
798
+ this.#syncJob(job);
708
799
  }),
709
800
  );
801
+ }
710
802
 
711
- return jobs.map((job) => ({
803
+ /** One public job row (live view, no remote probe). */
804
+ #jobRow(job) {
805
+ return {
712
806
  id: job.id,
713
807
  host: job.host,
714
- pid: job.pid,
808
+ pid: job.pid ?? null,
809
+ pgid: job.pgid ?? job.pid ?? null,
715
810
  state: job.state ?? 'unknown',
716
811
  exitCode: job.exitCode ?? null,
717
812
  exitSignal: job.exitSignal ?? null,
813
+ exitedAt: job.exitedAt ?? null,
718
814
  label: job.label ?? null,
719
815
  sessionId: job.sessionId ?? null,
720
- startedAt: job.startedAt,
721
- logPath: job.logPath,
816
+ startedAt: job.startedAt ?? null,
817
+ logPath: job.logPath ?? null,
818
+ commandLogPath: job.commandLogPath ?? null,
722
819
  logSize: job.logSize ?? null,
820
+ persisted: job.persisted === true,
723
821
  // Kept for the existing jobs view, which still prints the command line.
724
- command: job.command,
725
- cwd: job.cwd,
726
- }));
822
+ command: job.command ?? null,
823
+ cwd: job.cwd ?? null,
824
+ };
825
+ }
826
+
827
+ /**
828
+ * Next job id. The plain `jN` counter keeps ids readable; when a store is
829
+ * attached a short random suffix keeps two Harness windows that share the
830
+ * same jobs.json from overwriting each other's records.
831
+ */
832
+ #nextJobId() {
833
+ this.#counter += 1;
834
+ const base = `j${this.#counter}`;
835
+ return this.#jobStore ? `${base}-${randomUUID().slice(0, 4)}` : base;
836
+ }
837
+
838
+ #numericSuffix(id) {
839
+ const match = /^j(\d+)/u.exec(String(id ?? ''));
840
+ return match ? Number(match[1]) : 0;
841
+ }
842
+
843
+ /** Turn one stored record into an in-memory job; never assumes it is running. */
844
+ #adoptPersistedRecord(record) {
845
+ if (!record || typeof record !== 'object' || !record.id || this.#jobs.has(record.id)) return null;
846
+ const pid = Number.isFinite(Number(record.pid)) ? Number(record.pid) : null;
847
+ const pgid = Number.isFinite(Number(record.pgid)) ? Number(record.pgid) : pid;
848
+ const job = {
849
+ id: record.id,
850
+ host: record.host,
851
+ pid,
852
+ pgid,
853
+ logPath: record.logPath ?? null,
854
+ commandLogPath: record.commandLogPath ?? null,
855
+ statusPath: record.statusPath ?? null,
856
+ command: record.command ?? null,
857
+ cwd: record.cwd ?? null,
858
+ label: record.label ?? null,
859
+ sessionId: record.sessionId ?? null,
860
+ startedAt: record.startedAt ?? null,
861
+ // A finished record stays finished; anything else is `unknown` until a
862
+ // liveness probe proves the process group still exists.
863
+ state: record.state === 'exited' ? 'exited' : 'unknown',
864
+ exitCode: record.exitCode ?? null,
865
+ exitSignal: record.exitSignal ?? null,
866
+ exitedAt: record.exitedAt ?? null,
867
+ logSize: null,
868
+ logTail: '',
869
+ stopped: false,
870
+ persisted: true,
871
+ lastPersistedJson: null,
872
+ };
873
+ this.#jobs.set(job.id, job);
874
+ this.#counter = Math.max(this.#counter, this.#numericSuffix(job.id));
875
+ return job;
876
+ }
877
+
878
+ #storeRecords() {
879
+ const store = this.#jobStore;
880
+ if (!store || typeof store.list !== 'function') return [];
881
+ try {
882
+ const records = store.list();
883
+ return Array.isArray(records) ? records : [];
884
+ } catch {
885
+ return [];
886
+ }
887
+ }
888
+
889
+ /** Load the registry at startup; records are visible but not trusted as live. */
890
+ #loadPersistedJobs() {
891
+ for (const record of this.#storeRecords()) this.#adoptPersistedRecord(record);
892
+ }
893
+
894
+ /** Pick up records a second process (or an earlier prune) wrote meanwhile. */
895
+ #mergePersistedJobs() {
896
+ for (const record of this.#storeRecords()) this.#adoptPersistedRecord(record);
897
+ }
898
+
899
+ /**
900
+ * First connection to a host after a restart: resolve that machine's
901
+ * persisted jobs instead of leaving them `unknown` until the next listing.
902
+ * Called fire-and-forget from the connection handler; never affects dialling.
903
+ */
904
+ async #refreshPersistedJobs(host) {
905
+ const pending = [...this.#jobs.values()].filter(
906
+ (job) => job.host === host && job.persisted === true && job.state !== 'exited',
907
+ );
908
+ if (pending.length === 0) return;
909
+ await this.#probeHostJobs(host, pending);
910
+ }
911
+
912
+ /**
913
+ * Write one job through to the JSON store, skipping unchanged records: the
914
+ * state poll would otherwise rewrite the file every couple of seconds.
915
+ */
916
+ #syncJob(job) {
917
+ const store = this.#jobStore;
918
+ if (!store || typeof store.upsert !== 'function') return;
919
+ const record = {
920
+ id: job.id,
921
+ host: job.host,
922
+ label: job.label ?? null,
923
+ command: job.command ?? null,
924
+ sessionId: job.sessionId ?? null,
925
+ pid: job.pid ?? null,
926
+ pgid: job.pgid ?? job.pid ?? null,
927
+ logPath: job.logPath ?? null,
928
+ commandLogPath: job.commandLogPath ?? null,
929
+ statusPath: job.statusPath ?? null,
930
+ cwd: job.cwd ?? null,
931
+ startedAt: job.startedAt ?? null,
932
+ exitedAt: job.exitedAt ?? null,
933
+ exitCode: job.exitCode ?? null,
934
+ exitSignal: job.exitSignal ?? null,
935
+ state: job.state ?? 'unknown',
936
+ };
937
+ const json = JSON.stringify(record);
938
+ if (json === job.lastPersistedJson) return;
939
+ try {
940
+ store.upsert(record);
941
+ job.lastPersistedJson = json;
942
+ } catch {
943
+ /* a read-only home must never fail a tool; the live job still works */
944
+ }
727
945
  }
728
946
 
729
947
  /**
@@ -756,7 +974,11 @@ export class PresencePool {
756
974
  id: job.id,
757
975
  host: job.host,
758
976
  pid: job.pid,
977
+ pgid: job.pgid ?? job.pid ?? null,
978
+ // The wrapper log (exit status) and, when the kernel exposes one, the
979
+ // command's own stdout/stderr log. Older kernels only have `logPath`.
759
980
  logPath: job.logPath,
981
+ commandLogPath: job.commandLogPath ?? null,
760
982
  statusPath: job.statusPath,
761
983
  state,
762
984
  exitCode,