dsh-update-status 0.3.1 → 0.4.0

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/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { c as parseSystemdMainPid, d as bootStampFile, f as countRecentRestarts, h as serviceLogDir, i as SYSTEMD_UNIT, m as recordBootStamp, p as dshHomeOf, r as LAUNCHD_LABEL, s as parseLaunchdTrackedPid, t as planFor, u as RESTART_STORM_WINDOW_MS } from "./plan-BornHBEZ.js";
1
2
  import { createRequire } from "node:module";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
4
  import { basename, dirname, join, normalize, sep } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { homedir } from "node:os";
7
+ import { execFile } from "node:child_process";
6
8
  //#region src/shared/types.ts
7
9
  const PACKAGE_NAME = "@deepseek-ai/dsh";
8
10
  /** Shared Connection RPC channel. Custom prefixes 405 on the SPA fallback. */
@@ -242,14 +244,14 @@ function createUpdateStatusRpcHandler(service, restart, restartContext) {
242
244
  if (restart === void 0) return failure("dsh-update-status/restart-unavailable", "restart support is not configured");
243
245
  return {
244
246
  ok: true,
245
- value: restart.status()
247
+ value: await restart.assess()
246
248
  };
247
249
  }
248
250
  if (endpoint === RESTART_ENDPOINTS.check) {
249
251
  if (restart === void 0 || restartContext === void 0) return failure("dsh-update-status/restart-unavailable", "restart support is not configured");
250
252
  return {
251
253
  ok: true,
252
- value: restart.check(restartContext)
254
+ value: await restart.check(restartContext)
253
255
  };
254
256
  }
255
257
  if (endpoint === RESTART_ENDPOINTS.request) {
@@ -258,7 +260,7 @@ function createUpdateStatusRpcHandler(service, restart, restartContext) {
258
260
  if (restart === void 0 || restartContext === void 0) return failure("dsh-update-status/restart-unavailable", "restart support is not configured");
259
261
  return {
260
262
  ok: true,
261
- value: restart.request(restartContext, request.force, request.expectedInstanceId)
263
+ value: await restart.request(restartContext, request.force, request.expectedInstanceId)
262
264
  };
263
265
  }
264
266
  return failure("dsh-update-status/unknown-endpoint", `unknown endpoint: ${endpoint}`);
@@ -291,7 +293,228 @@ function installUpdateStatusRpc(ctx, service, restart) {
291
293
  }
292
294
  });
293
295
  }
296
+ //#endregion
297
+ //#region src/host/service-record.ts
298
+ /**
299
+ * What the *service* is, read back from our own installer receipt.
300
+ *
301
+ * The panel must be able to tell an operator how to get supervision back after
302
+ * it was lost — a restart storm tripped the wrapper's ceiling, or another tool's
303
+ * instance took the port. Those steps are platform commands, and the plugin
304
+ * never guesses them: the installer wrote the receipt, so the receipt is where
305
+ * the port and the matching recover command come from.
306
+ *
307
+ * Read-only and best effort. A missing, foreign or unreadable receipt simply
308
+ * means "no recovery hint", never an error on a status route.
309
+ */
310
+ const SERVICE_RECEIPT_FILE = "dsh-update-status-service.json";
311
+ /** Whoever is listening on the port, without touching it — per platform. */
312
+ function portListCommand(platform, port) {
313
+ if (platform === "win32") return `netstat -ano | findstr :${String(port)}`;
314
+ return `lsof -nP -iTCP:${String(port)} -sTCP:LISTEN`;
315
+ }
316
+ function specOf(value) {
317
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
318
+ const spec = value;
319
+ if (spec.platform !== "darwin" && spec.platform !== "linux" && spec.platform !== "win32") return void 0;
320
+ if (typeof spec.port !== "number" || !Number.isInteger(spec.port) || spec.port < 1 || spec.port > 65535) return void 0;
321
+ if (typeof spec.label !== "string" || spec.label === "") return void 0;
322
+ for (const field of [
323
+ spec.nodePath,
324
+ spec.dshPath,
325
+ spec.profile,
326
+ spec.workspace,
327
+ spec.host,
328
+ spec.home,
329
+ spec.logDir
330
+ ]) if (typeof field !== "string" || field === "") return void 0;
331
+ if (spec.supervisorMarker !== "dsh-update-status") return void 0;
332
+ return spec;
333
+ }
334
+ const CACHE_TTL_MS = 3e4;
335
+ /**
336
+ * Cached for a short window: the receipt changes only when the operator re-installs,
337
+ * and this runs on every restart-status poll while a restart is unavailable.
338
+ */
339
+ function createServiceRecovery(options = {}) {
340
+ const now = options.now ?? Date.now;
341
+ const read = options.readFile ?? ((path) => {
342
+ try {
343
+ if (!existsSync(path) || !statSync(path).isFile()) return void 0;
344
+ return readFileSync(path, "utf8");
345
+ } catch {
346
+ return;
347
+ }
348
+ });
349
+ let cached;
350
+ return () => {
351
+ const at = now();
352
+ if (cached !== void 0 && at - cached.at < CACHE_TTL_MS) return cached.value;
353
+ const value = readRecovery(options, read);
354
+ cached = {
355
+ at,
356
+ value
357
+ };
358
+ return value;
359
+ };
360
+ }
361
+ function readRecovery(options, read) {
362
+ const text = read(join(dshHomeOf(options.env ?? process.env), SERVICE_RECEIPT_FILE));
363
+ if (text === void 0) return void 0;
364
+ try {
365
+ const receipt = JSON.parse(text);
366
+ if (receipt.version !== 1 || receipt.managedBy !== "dsh-update-status") return void 0;
367
+ const spec = specOf(receipt.spec);
368
+ if (spec === void 0) return void 0;
369
+ const plan = planFor(spec, options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0));
370
+ return { commands: `${portListCommand(spec.platform, spec.port)}\n${plan.recoverCommand}` };
371
+ } catch {
372
+ return;
373
+ }
374
+ }
375
+ //#endregion
376
+ //#region src/host/supervision.ts
377
+ /**
378
+ * Platform ownership of the running DSH Web process, plus restart-storm evidence.
379
+ *
380
+ * The environment marker (`DSH_WEB_SUPERVISOR`) is necessary but *not* sufficient:
381
+ * every descendant of a supervised process inherits it. A foreign launcher — a
382
+ * second plugin's restart helper, a `nohup` script — therefore looks supervised
383
+ * while the real job is stuck respawning a process that can never bind the port.
384
+ * The panel would offer a destructive restart and promise a recovery the platform
385
+ * cannot deliver, so ownership is asked of the platform itself:
386
+ *
387
+ * - launchd: the tracked pid of our fixed job label must be this process or its
388
+ * wrapper parent (asked by label, never from the environment);
389
+ * - systemd: the unit's `MainPID` must be this process or its wrapper parent;
390
+ * - Task Scheduler: the parent process must be the wrapper this plugin installed.
391
+ *
392
+ * Anything unverifiable fails closed: restart is destructive, and a false "yes"
393
+ * strands the page, while a false "no" only asks the user to restart DSH by hand.
394
+ */
395
+ function defaultRun(file, args) {
396
+ return new Promise((resolve) => {
397
+ execFile(file, [...args], {
398
+ encoding: "utf8",
399
+ timeout: 5e3,
400
+ windowsHide: true
401
+ }, (error, stdout, stderr) => {
402
+ const code = error === null ? 0 : error.code;
403
+ resolve({
404
+ status: typeof code === "number" ? code : error === null ? 0 : null,
405
+ stdout: stdout ?? "",
406
+ stderr: stderr ?? "",
407
+ ...error === null ? {} : { error: error.message }
408
+ });
409
+ });
410
+ });
411
+ }
412
+ async function probeSupervisorOwnership(options = {}) {
413
+ const platform = options.platform ?? process.platform;
414
+ const pid = options.pid ?? process.pid;
415
+ const ppid = options.ppid ?? process.ppid;
416
+ const run = options.run ?? defaultRun;
417
+ try {
418
+ if (platform === "darwin") {
419
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : void 0);
420
+ if (uid === void 0) return "unverifiable";
421
+ const result = await run("/bin/launchctl", ["print", `gui/${String(uid)}/${LAUNCHD_LABEL}`]);
422
+ if (result.status === null && result.error !== void 0) return "unverifiable";
423
+ const tracked = parseLaunchdTrackedPid(result.stdout);
424
+ if (tracked === void 0) return "not-owner";
425
+ return tracked === pid || tracked === ppid ? "owned" : "not-owner";
426
+ }
427
+ if (platform === "linux") {
428
+ const result = await run("systemctl", [
429
+ "--user",
430
+ "show",
431
+ SYSTEMD_UNIT,
432
+ "-p",
433
+ "MainPID",
434
+ "--value"
435
+ ]);
436
+ if (result.status === null && result.error !== void 0) return "unverifiable";
437
+ const tracked = parseSystemdMainPid(result.stdout);
438
+ if (tracked === void 0) return "not-owner";
439
+ return tracked === pid || tracked === ppid ? "owned" : "not-owner";
440
+ }
441
+ if (platform === "win32") {
442
+ const result = await run("powershell.exe", [
443
+ "-NoProfile",
444
+ "-NonInteractive",
445
+ "-Command",
446
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${String(ppid)}").CommandLine`
447
+ ]);
448
+ if (result.status === null && result.error !== void 0) return "unverifiable";
449
+ return result.stdout.includes("dsh-web-supervisor.ps1") ? "owned" : "not-owner";
450
+ }
451
+ return "unverifiable";
452
+ } catch {
453
+ return "unverifiable";
454
+ }
455
+ }
456
+ /**
457
+ * Ownership is cheap to cache: the answer cannot change while a restart is not in
458
+ * flight, and the status route is polled by every open panel.
459
+ */
460
+ function cacheOwnershipProbe(probe, ttlMs = 1e4, now = Date.now) {
461
+ let cached;
462
+ let inflight;
463
+ return async () => {
464
+ if (cached !== void 0 && now() - cached.at < ttlMs) return cached.verdict;
465
+ if (inflight !== void 0) return inflight;
466
+ inflight = probe().catch(() => "unverifiable").then((verdict) => {
467
+ cached = {
468
+ at: now(),
469
+ verdict
470
+ };
471
+ return verdict;
472
+ }).finally(() => {
473
+ inflight = void 0;
474
+ });
475
+ return inflight;
476
+ };
477
+ }
478
+ function classifySupervisorHealth(ownership, recentRestarts, windowMs = RESTART_STORM_WINDOW_MS, threshold = 4) {
479
+ if (ownership === "unverifiable") return { kind: "unverifiable" };
480
+ if (ownership === "not-owner") return { kind: "not-owner" };
481
+ if (recentRestarts >= threshold) return {
482
+ kind: "thrashing",
483
+ recentRestarts,
484
+ windowMs
485
+ };
486
+ return { kind: "owned" };
487
+ }
488
+ /**
489
+ * Ownership first, storm second: "not the supervisor's own process" is the more
490
+ * precise explanation whenever both are true.
491
+ */
492
+ function createSupervisorHealth(options) {
493
+ const now = options.now ?? Date.now;
494
+ const windowMs = options.windowMs ?? 18e4;
495
+ const threshold = options.threshold ?? 4;
496
+ const probe = options.probe ?? cacheOwnershipProbe(() => probeSupervisorOwnership(), 1e4, now);
497
+ return async () => {
498
+ const ownership = await probe();
499
+ if (ownership !== "owned") return classifySupervisorHealth(ownership, 0, windowMs, threshold);
500
+ return classifySupervisorHealth(ownership, countRecentRestarts(options.logDir, options.bootLog, now(), windowMs), windowMs, threshold);
501
+ };
502
+ }
503
+ const BOOT_STAMPED = Symbol.for("dsh-update-status/boot-stamped");
504
+ /**
505
+ * One stamp per Node process, not per plugin composition: DSH can recompose this
506
+ * plugin (HMR) inside a generation that already stamped itself, and counting
507
+ * those would fake a storm.
508
+ */
509
+ function recordBootOnce(stamp, path = bootStampFile()) {
510
+ const carrier = globalThis;
511
+ if (carrier[BOOT_STAMPED] === true) return false;
512
+ carrier[BOOT_STAMPED] = true;
513
+ recordBootStamp(path, stamp);
514
+ return true;
515
+ }
294
516
  const INSTANCE_KEY = Symbol.for("dsh-update-status/process-instance-id");
517
+ const STARTED_AT_KEY = Symbol.for("dsh-update-status/process-started-at");
295
518
  /**
296
519
  * This must outlive plugin HMR/recomposition inside one Node process. A module
297
520
  * local random value would make a Host reload look like a successful restart.
@@ -305,63 +528,76 @@ function processInstanceId() {
305
528
  return created;
306
529
  }
307
530
  /**
308
- * Strict supervisor recognition: the marker is written only by this package's
309
- * user-service installer. Platform-specific signals defend against a user
310
- * copying that environment variable into an arbitrary terminal by accident.
531
+ * Wall-clock start of this process, derived once from `process.uptime()` — the
532
+ * one clock the Host owns without extra bookkeeping. It is cached on globalThis
533
+ * for the same reason as the instance id: a recomposed plugin must keep reporting
534
+ * the start of the process it is running in, not the moment it was recomposed.
535
+ */
536
+ function processStartedAt() {
537
+ const carrier = globalThis;
538
+ const existing = carrier[STARTED_AT_KEY];
539
+ if (existing !== void 0) return existing;
540
+ const created = new Date(Date.now() - Math.round(process.uptime() * 1e3)).toISOString();
541
+ carrier[STARTED_AT_KEY] = created;
542
+ return created;
543
+ }
544
+ /**
545
+ * The environment/platform half of the verdict: which supervisor this process
546
+ * *claims* to be running under.
547
+ *
548
+ * The explicit marker is never inherited by accident, but every descendant of a
549
+ * supervised process does inherit it, so this function alone can only report a
550
+ * claim. What turns a claim into an answer is `assess()` below, which asks the
551
+ * platform which process the service actually owns. The platform identity is
552
+ * deliberately NOT read from the environment: launchd configures
553
+ * `XPC_SERVICE_NAME=<label>` for the job, yet a DSH process behind the staged
554
+ * `/bin/sh` wrapper reads `XPC_SERVICE_NAME=0`, so an environment check would
555
+ * refuse the very setup this plugin installs.
311
556
  */
312
557
  function restartStatusOf(options = {}) {
313
558
  const env = options.env ?? process.env;
314
559
  const platform = options.platform ?? process.platform;
315
560
  const versions = options.versions ?? process.versions;
316
561
  const instanceId = options.instanceId ?? processInstanceId();
562
+ const startedAt = options.startedAt ?? processStartedAt();
317
563
  if (versions.electron !== void 0 && versions.electron !== "") return {
318
564
  instanceId,
565
+ startedAt,
319
566
  available: false,
320
567
  supervisor: null,
321
568
  unavailableReason: "desktop"
322
569
  };
323
570
  if (env.DSH_WEB_SUPERVISOR !== "dsh-update-status") return {
324
571
  instanceId,
572
+ startedAt,
325
573
  available: false,
326
574
  supervisor: null,
327
575
  unavailableReason: "not-supervised"
328
576
  };
329
- if (platform === "darwin") {
330
- if (env.XPC_SERVICE_NAME !== "com.idoall.dsh-update-status.web") return {
331
- instanceId,
332
- available: false,
333
- supervisor: "launchd",
334
- unavailableReason: "supervisor-mismatch"
335
- };
336
- return {
337
- instanceId,
338
- available: true,
339
- supervisor: "launchd",
340
- unavailableReason: null
341
- };
342
- }
343
- if (platform === "linux") {
344
- if (typeof env.INVOCATION_ID !== "string" || env.INVOCATION_ID === "") return {
345
- instanceId,
346
- available: false,
347
- supervisor: "systemd",
348
- unavailableReason: "supervisor-mismatch"
349
- };
350
- return {
351
- instanceId,
352
- available: true,
353
- supervisor: "systemd",
354
- unavailableReason: null
355
- };
356
- }
577
+ if (platform === "darwin") return {
578
+ instanceId,
579
+ startedAt,
580
+ available: true,
581
+ supervisor: "launchd",
582
+ unavailableReason: null
583
+ };
584
+ if (platform === "linux") return {
585
+ instanceId,
586
+ startedAt,
587
+ available: true,
588
+ supervisor: "systemd",
589
+ unavailableReason: null
590
+ };
357
591
  if (platform === "win32") return {
358
592
  instanceId,
593
+ startedAt,
359
594
  available: true,
360
595
  supervisor: "task-scheduler",
361
596
  unavailableReason: null
362
597
  };
363
598
  return {
364
599
  instanceId,
600
+ startedAt,
365
601
  available: false,
366
602
  supervisor: "unknown",
367
603
  unavailableReason: "supervisor-mismatch"
@@ -460,10 +696,14 @@ function collectActiveWork(ctx) {
460
696
  var RestartController = class {
461
697
  instanceId;
462
698
  options;
699
+ health;
700
+ recovery;
463
701
  scheduled = false;
464
702
  constructor(options = {}) {
465
703
  this.options = options;
466
704
  this.instanceId = options.instanceId ?? processInstanceId();
705
+ this.health = options.health;
706
+ this.recovery = options.recovery;
467
707
  }
468
708
  status() {
469
709
  return restartStatusOf({
@@ -471,8 +711,50 @@ var RestartController = class {
471
711
  instanceId: this.instanceId
472
712
  });
473
713
  }
474
- check(ctx) {
475
- const status = this.status();
714
+ /**
715
+ * The environment verdict plus what only the platform knows: whether this
716
+ * process is the one its supervisor is actually tracking, and whether a restart
717
+ * storm says the supervisor cannot complete a start at all. A probe that throws
718
+ * is treated as unverifiable, because restart is destructive.
719
+ */
720
+ async assess() {
721
+ const base = this.status();
722
+ if (!base.available) return this.withRecovery(base);
723
+ let health;
724
+ if (this.health === void 0) health = { kind: "unverifiable" };
725
+ else try {
726
+ health = await this.health();
727
+ } catch {
728
+ health = { kind: "unverifiable" };
729
+ }
730
+ if (health.kind === "thrashing") return this.withRecovery({
731
+ ...base,
732
+ available: false,
733
+ unavailableReason: "supervisor-thrashing"
734
+ });
735
+ if (health.kind === "not-owner" || health.kind === "unverifiable") return this.withRecovery({
736
+ ...base,
737
+ available: false,
738
+ unavailableReason: "supervisor-mismatch"
739
+ });
740
+ return base;
741
+ }
742
+ /** A refused restart carries the steps back; an offerable one does not need them. */
743
+ withRecovery(status) {
744
+ if (this.recovery === void 0) return status;
745
+ let recovery;
746
+ try {
747
+ recovery = this.recovery();
748
+ } catch {
749
+ recovery = void 0;
750
+ }
751
+ return recovery === void 0 ? status : {
752
+ ...status,
753
+ recovery
754
+ };
755
+ }
756
+ async check(ctx) {
757
+ const status = await this.assess();
476
758
  if (!status.available) return {
477
759
  kind: "unavailable",
478
760
  status
@@ -496,7 +778,7 @@ var RestartController = class {
496
778
  status
497
779
  };
498
780
  }
499
- request(ctx, force, expectedInstanceId) {
781
+ async request(ctx, force, expectedInstanceId) {
500
782
  if (expectedInstanceId !== this.instanceId) return {
501
783
  kind: "unavailable",
502
784
  status: {
@@ -505,7 +787,7 @@ var RestartController = class {
505
787
  unavailableReason: "stale-instance"
506
788
  }
507
789
  };
508
- const checked = this.check(ctx);
790
+ const checked = await this.check(ctx);
509
791
  if (checked.kind === "unavailable") return checked;
510
792
  if (checked.kind === "active-work" && !force) return checked;
511
793
  if (this.scheduled) return {
@@ -542,9 +824,24 @@ var RestartController = class {
542
824
  };
543
825
  }
544
826
  };
545
- /** Creates the process-scoped controller once per DSH Host generation. */
546
- function createRestartController() {
547
- return new RestartController();
827
+ /**
828
+ * Creates the process-scoped controller once per DSH Host generation.
829
+ *
830
+ * The health probe is wired here rather than inside the CLI's plan so the Host
831
+ * and the installer agree on where the evidence lives: `$DSH_HOME/logs`.
832
+ */
833
+ function createRestartController(options = {}) {
834
+ const env = options.env ?? process.env;
835
+ const health = options.health ?? createSupervisorHealth({
836
+ logDir: serviceLogDir(env),
837
+ bootLog: bootStampFile(env)
838
+ });
839
+ const recovery = options.recovery ?? createServiceRecovery({ env });
840
+ return new RestartController({
841
+ ...options,
842
+ health,
843
+ recovery
844
+ });
548
845
  }
549
846
  //#endregion
550
847
  //#region src/host/schemastery.ts
@@ -1178,7 +1475,14 @@ function apply(ctx, config = {}) {
1178
1475
  runtimeWarnings: schema.warning === null ? [] : [schema.warning]
1179
1476
  });
1180
1477
  installSettings(ctx);
1181
- installUpdateStatusRpc(ctx, service, createRestartController());
1478
+ const restart = createRestartController();
1479
+ const restartStatus = restart.status();
1480
+ recordBootOnce({
1481
+ pid: process.pid,
1482
+ instanceId: restartStatus.instanceId,
1483
+ supervisor: restartStatus.supervisor
1484
+ });
1485
+ installUpdateStatusRpc(ctx, service, restart);
1182
1486
  if (config.autoCheckOnMount !== false) service.getStatus();
1183
1487
  }
1184
1488
  //#endregion