@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/README.md +92 -23
- package/README.zh-CN.md +81 -20
- package/index.js +138 -89
- package/package.json +1 -1
- package/src/api.js +6 -1
- package/src/intercept.js +150 -0
- package/src/job-store.js +292 -0
- package/src/observed-store.js +136 -19
- package/src/pool.js +308 -86
- package/src/tools.js +845 -154
- package/src/wait.js +352 -0
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 =
|
|
17
|
-
const JOB_PRUNE_MAX_ENTRIES =
|
|
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
|
-
|
|
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 =
|
|
392
|
+
const id = this.#nextJobId();
|
|
393
|
+
const pid = Number(started.pid);
|
|
377
394
|
const job = {
|
|
378
395
|
id,
|
|
379
396
|
host,
|
|
380
|
-
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
|
-
|
|
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
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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,
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
-
|
|
646
|
-
const
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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,
|