@monster-orange/den 0.2.8 → 0.3.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -9,6 +9,7 @@
9
9
  import { hasRuntime, repoKind } from "../repo-kind.js";
10
10
  import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
11
11
  import { manifestStackName } from "../stacks/config.js";
12
+ import { claimHealthyStack } from "../stacks/owners.js";
12
13
  import fs from "fs";
13
14
  import path from "path";
14
15
  import { readManifest } from "../manifest/read-manifest.js";
@@ -72,6 +73,17 @@ import type { ChildProcess } from "child_process";
72
73
  import { getDistRoot, getStateDir } from "../paths.js";
73
74
 
74
75
  import { product } from "../product.js";
76
+ import {
77
+ adoptLegacyProcess,
78
+ busyListenerText,
79
+ servicePort,
80
+ } from "../process/legacy-adopt.js";
81
+ import {
82
+ processGroupStateSync,
83
+ recordProcessGroup,
84
+ stopProcessGroup,
85
+ type RecordedProcessGroup,
86
+ } from "../process/process-group.js";
75
87
  // Re-export so existing callers don't need to change their import path.
76
88
  export type { ServiceHealthResult };
77
89
  export { checkServiceHealth, checkCatalogHealth };
@@ -108,6 +120,23 @@ export interface DaemonRecord {
108
120
  * written before 0.2.7.
109
121
  */
110
122
  root?: string;
123
+ /**
124
+ * The process group a detached `up` started (its leader's pid), so `down`
125
+ * can stop a service that has no down command and no container
126
+ * (0.3.0-dev.37). Missing on records written before that.
127
+ */
128
+ pgid?: number;
129
+ /**
130
+ * The group leader's start time (`ps -o lstart=`). `down` signals the
131
+ * group only while the process holding `pgid` started at this time, so a
132
+ * reused pid is never signalled.
133
+ */
134
+ pgidStartedAt?: string;
135
+ /**
136
+ * A pid some writers before 0.3.0-dev.37 kept. Only a hint for adopting
137
+ * such a record (legacy-adopt.ts); never signalled on its own.
138
+ */
139
+ pid?: number;
111
140
  }
112
141
 
113
142
  /** This process's distribution root, as stamped on its daemon records. */
@@ -325,6 +354,34 @@ export function updateDaemonContainers(
325
354
  saveDaemonsFile(getDaemonsFile());
326
355
  }
327
356
 
357
+ /** Record the process group a detached `up` just spawned. */
358
+ export function updateDaemonProcessGroup(
359
+ address: string,
360
+ group: RecordedProcessGroup,
361
+ ): void {
362
+ loadDaemonsFromFile();
363
+ const existing = _daemonRegistry.get(address);
364
+ if (!existing) return;
365
+ _daemonRegistry.set(address, {
366
+ ...existing,
367
+ pgid: group.pgid,
368
+ pgidStartedAt: group.startedAt,
369
+ });
370
+ saveDaemonsFile(getDaemonsFile());
371
+ }
372
+
373
+ /** Forget a record's process group (it is gone; nothing of it to stop). */
374
+ function clearDaemonProcessGroup(address: string): void {
375
+ loadDaemonsFromFile();
376
+ const existing = _daemonRegistry.get(address);
377
+ if (!existing) return;
378
+ const rest = { ...existing };
379
+ delete rest.pgid;
380
+ delete rest.pgidStartedAt;
381
+ _daemonRegistry.set(address, rest);
382
+ saveDaemonsFile(getDaemonsFile());
383
+ }
384
+
328
385
  /**
329
386
  * This root's daemons (and unstamped legacy ones). Every root of a product
330
387
  * shares one daemons file; another root's records stay in it untouched but
@@ -573,8 +630,21 @@ export function adoptRunningContainers(
573
630
  export function claimHealthyService(
574
631
  resolved: ResolvedService,
575
632
  workspace: string,
576
- ): "attached" | "adopted" | "untracked" {
577
- const existing = findDaemonByAddress(resolved.address);
633
+ onLine: (line: string) => void = () => {},
634
+ ): "attached" | "adopted" | "untracked" | "conflict" | "busy" {
635
+ // A compose stack's primary service: its owner is recorded with the
636
+ // stack's project (a shared stack namespace's refcount, stacks/owners.ts).
637
+ // `conflict`: the running project is another checkout's; the caller runs
638
+ // the real up, which refuses it.
639
+ const stack = claimHealthyStack(resolved, workspace);
640
+ if (stack) return stack;
641
+ // A record from before 0.3.0-dev.37 is settled (adopted or dropped)
642
+ // before anything is decided on it (0.3.0-dev.39, a consumer's dev.38
643
+ // finding: the healthy-skip attached to it and it never gained a group).
644
+ const settled = settleDaemonRecord(resolved, workspace, onLine);
645
+ // Dropped with its port held from outside the repo: said by the settle.
646
+ if (settled.outcome === "busy") return "busy";
647
+ const existing = settled.record;
578
648
  if (existing) {
579
649
  if (!sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)) {
580
650
  // Healthy, but the record describes a different checkout — leave the
@@ -584,9 +654,17 @@ export function claimHealthyService(
584
654
  attachDaemonOwner(resolved.address, workspace);
585
655
  return "attached";
586
656
  }
587
- return adoptRunningContainers(resolved, workspace).length > 0
588
- ? "adopted"
589
- : "untracked";
657
+ if (adoptRunningContainers(resolved, workspace).length > 0) return "adopted";
658
+ // Healthy with no record: whose process answers on the service's port?
659
+ // One in the repo is the service (adopted, so `down` can stop it); one
660
+ // from elsewhere is not, and is said, never claimed or touched.
661
+ const holder = settleUntrackedListener(
662
+ resolved,
663
+ workspace,
664
+ onLine,
665
+ `${resolved.address} not started`,
666
+ );
667
+ return holder ?? "untracked";
590
668
  }
591
669
 
592
670
  /**
@@ -644,14 +722,78 @@ export function findDaemonConflict(
644
722
  }
645
723
 
646
724
  function canResolveDaemonAddress(d: DaemonRecord): boolean {
647
- if (resolveService(d.address)) return true;
648
- for (const w of d.workspaces ?? []) {
649
- if (resolveService(d.address, w)) return true;
650
- }
651
- for (const w of getActiveWorkspaces()) {
652
- if (resolveService(d.address, w)) return true;
653
- }
654
- return false;
725
+ return resolvedRepoPathsOf(d).length > 0;
726
+ }
727
+
728
+ /**
729
+ * This root's records whose address resolves in no active workspace: what
730
+ * `down -a` (every workspace) cannot reach, and so warns about and leaves
731
+ * alone (0.3.0-dev.39).
732
+ */
733
+ export function daemonsOutsideWorkspaces(): DaemonRecord[] {
734
+ return listDaemons().filter((d) => !canResolveDaemonAddress(d));
735
+ }
736
+
737
+ /**
738
+ * Every checkout a daemon's address resolves to now: through the focus,
739
+ * each workspace that owns it, then every other active workspace (the
740
+ * same order canResolveDaemonAddress and the reaper use). Empty when the
741
+ * address no longer resolves anywhere.
742
+ */
743
+ function resolvedRepoPathsOf(d: DaemonRecord): string[] {
744
+ const paths = new Set<string>();
745
+ const add = (r: ResolvedService | null) => {
746
+ if (r) paths.add(r.repoPath);
747
+ };
748
+ add(resolveService(d.address));
749
+ for (const w of new Set([...(d.workspaces ?? []), ...getActiveWorkspaces()]))
750
+ add(resolveService(d.address, w));
751
+ return [...paths];
752
+ }
753
+
754
+ /** Why a daemon record is stale whatever its address resolves to, or null. */
755
+ export type DaemonStaleReason = "repo-path-missing" | "relocated";
756
+
757
+ /**
758
+ * A record that describes nothing that can be running (0.3.0-dev.33, a
759
+ * consumer's dogfood finding): a record whose repo was relocated kept its
760
+ * Runs tab forever, because its address still resolved and, with no
761
+ * containers captured, nothing could prove it dead.
762
+ *
763
+ * - "repo-path-missing": its repoPath is not on disk. Nothing of it can
764
+ * run from a checkout that does not exist, whichever root wrote it.
765
+ * - "relocated": the address resolves now, but never to its repoPath, and
766
+ * nothing of it is live: none of its candidate containers (captured, or
767
+ * the dev-op's at its old path) is running. The repo name is not a
768
+ * candidate here: the relocated checkout's own container carries it.
769
+ * Records carry no pid, so there is no process to check. Needs the
770
+ * container listing when it has candidates (null: unverifiable, kept).
771
+ *
772
+ * Another root's record is never this root's call (null). A record that
773
+ * resolves to its own repoPath is never stale here: it may be a live
774
+ * non-opctl process, and isDaemonVerifiablyDead keeps that rule.
775
+ */
776
+ export function daemonStaleReason(
777
+ d: DaemonRecord,
778
+ running: Set<string> | null,
779
+ deps: {
780
+ exists?: (p: string) => boolean;
781
+ resolvedPaths?: (d: DaemonRecord) => string[];
782
+ devOpNames?: (repoPath: string) => string[];
783
+ } = {},
784
+ ): DaemonStaleReason | null {
785
+ if (daemonRootOf(d) === "other") return null;
786
+ const exists = deps.exists ?? fs.existsSync;
787
+ if (!d.repoPath || !exists(d.repoPath)) return "repo-path-missing";
788
+ const now = (deps.resolvedPaths ?? resolvedRepoPathsOf)(d);
789
+ if (now.length === 0 || now.includes(d.repoPath)) return null;
790
+ const candidates = [
791
+ ...(d.containers ?? []),
792
+ ...(deps.devOpNames ?? devOpContainerNames)(d.repoPath),
793
+ ].filter(Boolean);
794
+ if (candidates.length === 0) return "relocated";
795
+ if (!running) return null;
796
+ return candidates.some((c) => running.has(c)) ? null : "relocated";
655
797
  }
656
798
 
657
799
  /**
@@ -691,6 +833,9 @@ export function isDaemonVerifiablyDead(
691
833
  * - it is opctl-based (has captured containers or a discoverable dev-op) and
692
834
  * NONE of its candidate containers are currently running.
693
835
  *
836
+ * - its checkout is gone, or the service now resolves to another checkout
837
+ * and nothing of the record's is running (daemonStaleReason).
838
+ *
694
839
  * Daemons with no discoverable opctl containers are KEPT — we can't verify them
695
840
  * via `opctl container ls`, so we err toward keeping a possibly-live process
696
841
  * (e.g. a non-opctl dev server). Health is deliberately NOT used: a service's
@@ -714,10 +859,14 @@ export function pruneDeadDaemons(): string[] {
714
859
  export async function pruneDeadDaemonsAsync(): Promise<string[]> {
715
860
  const daemons = listDaemons();
716
861
  if (daemons.length === 0) return [];
717
- const running = (await isOpctlAvailableAsync())
862
+ return pruneAgainst(daemons, await runningContainerBasesAsync());
863
+ }
864
+
865
+ /** The running opctl containers' base names, or null without opctl. */
866
+ export async function runningContainerBasesAsync(): Promise<Set<string> | null> {
867
+ return (await isOpctlAvailableAsync())
718
868
  ? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
719
869
  : null;
720
- return pruneAgainst(daemons, running);
721
870
  }
722
871
 
723
872
  function pruneAgainst(
@@ -738,6 +887,13 @@ function pruneAgainst(
738
887
  pruned.push(d.address);
739
888
  continue;
740
889
  }
890
+ // A checkout that is gone, or one the service has moved away from
891
+ // with nothing of it still running (here or unstamped).
892
+ if (daemonStaleReason(d, running)) {
893
+ unregisterDaemon(d.address);
894
+ pruned.push(d.address);
895
+ continue;
896
+ }
741
897
  if (isDaemonVerifiablyDead(d, running)) {
742
898
  unregisterDaemon(d.address);
743
899
  pruned.push(d.address);
@@ -1112,17 +1268,22 @@ function opctlDownBasesFromCommand(cmd: string): string[] {
1112
1268
  return bases;
1113
1269
  }
1114
1270
 
1271
+ /**
1272
+ * Whether a service command runs detached (a daemon whose log is tailed)
1273
+ * or to completion. Only the commands that start a service, up and restart
1274
+ * (which ends with a long-running up), detach by default; a restart that
1275
+ * finishes on its own says `detachable: false`. Every other command is
1276
+ * one-shot unless it says `detachable: true` (0.3.0-dev.40, DOC-1: every
1277
+ * string command but down and setup detached, so a preflight run from the
1278
+ * Services page never finished). down and setup never detach.
1279
+ */
1115
1280
  export function shouldDetachServiceCommand(
1116
1281
  command: string,
1117
1282
  cmdDef: CommandDef,
1118
1283
  ): boolean {
1119
- return command === "down" || command === "setup"
1120
- ? false
1121
- : typeof cmdDef === "string"
1122
- ? true
1123
- : // restart ends with a long-running up, so it detaches unless the
1124
- // command says it finishes on its own (`detachable: false`).
1125
- (cmdDef.detachable ?? true);
1284
+ if (command === "down" || command === "setup") return false;
1285
+ const starts = command === "up" || command === "restart";
1286
+ return typeof cmdDef === "string" ? starts : (cmdDef.detachable ?? starts);
1126
1287
  }
1127
1288
 
1128
1289
  export function isInteractiveServiceCommand(cmdDef: CommandDef): boolean {
@@ -1175,6 +1336,356 @@ export interface RunCommandOptions {
1175
1336
  * declares secret_env is refused unless every key is here.
1176
1337
  */
1177
1338
  secretEnv?: Record<string, string>;
1339
+ /**
1340
+ * For `down`: whether the caller named this service (default true). When
1341
+ * down cannot stop a service it knows is running it warns; an explicit
1342
+ * down then exits non-zero, a workspace-wide one does not.
1343
+ */
1344
+ explicit?: boolean;
1345
+ /** For `down`: what it found to stop (see DownOutcome). */
1346
+ onDownOutcome?: (outcome: DownOutcome) => void;
1347
+ }
1348
+
1349
+ /**
1350
+ * What a `down` of a service with no down command and no container did, for
1351
+ * callers that report it: it "stopped" the process group `up` recorded,
1352
+ * found it "already-exited", had "nothing-recorded" to stop, or could not
1353
+ * stop what it knows is running ("unstoppable").
1354
+ */
1355
+ export type DownOutcome =
1356
+ | "stopped"
1357
+ | "already-exited"
1358
+ | "nothing-recorded"
1359
+ | "unstoppable";
1360
+
1361
+ /** SIGTERM-to-SIGKILL grace for a recorded process group. */
1362
+ function downGraceMs(): number {
1363
+ const raw = process.env.DEN_DOWN_GRACE_MS;
1364
+ const ms = raw === undefined ? NaN : Number(raw);
1365
+ return Number.isFinite(ms) && ms >= 0 ? ms : 3000;
1366
+ }
1367
+
1368
+ type GroupRecord = DaemonRecord & { pgid: number; pgidStartedAt: string };
1369
+
1370
+ /** Whether a record carries a process group `down` can verify and signal. */
1371
+ function hasProcessGroup(d: DaemonRecord | undefined): d is GroupRecord {
1372
+ return !!d && typeof d.pgid === "number" && !!d.pgidStartedAt;
1373
+ }
1374
+
1375
+ /**
1376
+ * Stop the process group a detached `up` recorded for this daemon (SIGTERM,
1377
+ * grace, SIGKILL; see process-group.ts), say what happened on `onLine`, and
1378
+ * unregister the record once nothing of it runs.
1379
+ */
1380
+ async function stopDaemonProcessGroup(
1381
+ record: GroupRecord,
1382
+ onLine: (line: string) => void,
1383
+ ): Promise<DownOutcome> {
1384
+ const result = await stopProcessGroup(
1385
+ { pgid: record.pgid, startedAt: record.pgidStartedAt },
1386
+ downGraceMs(),
1387
+ );
1388
+ switch (result.outcome) {
1389
+ case "stopped":
1390
+ onLine(
1391
+ `stopped ${record.address}: process group ${record.pgid} (${result.signal})`,
1392
+ );
1393
+ unregisterDaemon(record.address);
1394
+ return "stopped";
1395
+ case "gone":
1396
+ onLine(
1397
+ `${record.address} had already exited (process group ${record.pgid})`,
1398
+ );
1399
+ unregisterDaemon(record.address);
1400
+ return "already-exited";
1401
+ case "reused":
1402
+ // The recorded process exited; whatever holds its pid now is not ours.
1403
+ onLine(
1404
+ `${record.address} had already exited: ${result.detail}; left it alone`,
1405
+ );
1406
+ unregisterDaemon(record.address);
1407
+ return "already-exited";
1408
+ case "survived":
1409
+ onLine(
1410
+ `Warning: could not stop ${record.address}: process group ${record.pgid} survived SIGKILL (pids ${result.members.join(", ")})`,
1411
+ );
1412
+ return "unstoppable";
1413
+ case "error":
1414
+ onLine(`Warning: could not stop ${record.address}: ${result.detail}`);
1415
+ return "unstoppable";
1416
+ }
1417
+ }
1418
+
1419
+ /**
1420
+ * The daemon records of `repo`'s own services (`<repo>/<svc>`), this root's
1421
+ * or unstamped, other than `except`: what a repo-level down stops.
1422
+ */
1423
+ function repoServiceRecords(repo: string, except: string): DaemonRecord[] {
1424
+ return listDaemons().filter(
1425
+ (d) =>
1426
+ d.address !== except &&
1427
+ d.address.startsWith(`${repo}/`) &&
1428
+ daemonRootOf(d) !== "other",
1429
+ );
1430
+ }
1431
+
1432
+ /** Which of two down outcomes says more: what a fanned-out down reports. */
1433
+ const DOWN_RANK: Record<DownOutcome, number> = {
1434
+ "nothing-recorded": 0,
1435
+ "already-exited": 1,
1436
+ stopped: 2,
1437
+ unstoppable: 3,
1438
+ };
1439
+
1440
+ /**
1441
+ * Down each address in turn (each through runServiceCommand's own path:
1442
+ * its refcount, its down command or its recorded process group), lines to
1443
+ * the caller's onLine. Resolves the outcome that says most and the worst
1444
+ * exit code.
1445
+ */
1446
+ async function downEach(
1447
+ addresses: string[],
1448
+ options: RunCommandOptions,
1449
+ ): Promise<{ outcome: DownOutcome; exitCode: number }> {
1450
+ let outcome: DownOutcome = "nothing-recorded";
1451
+ let exitCode = 0;
1452
+ for (const address of addresses) {
1453
+ let own: DownOutcome | null = null;
1454
+ const code = await new Promise<number>((resolve) => {
1455
+ runServiceCommand({
1456
+ ...options,
1457
+ address,
1458
+ command: "down",
1459
+ onDownOutcome: (o) => {
1460
+ own = o;
1461
+ },
1462
+ onClose: resolve,
1463
+ });
1464
+ });
1465
+ // A service with a down command of its own reports no outcome: a
1466
+ // success is a stop.
1467
+ const o: DownOutcome = own ?? (code === 0 ? "stopped" : "unstoppable");
1468
+ if (DOWN_RANK[o] > DOWN_RANK[outcome]) outcome = o;
1469
+ exitCode = Math.max(exitCode, code);
1470
+ }
1471
+ return { outcome, exitCode };
1472
+ }
1473
+
1474
+ /**
1475
+ * A record `up` and `down` can't verify by its process group: none was
1476
+ * recorded (before 0.3.0-dev.37) and it names no container of its own.
1477
+ */
1478
+ function isLegacyRecord(d: DaemonRecord): boolean {
1479
+ return (
1480
+ !hasProcessGroup(d) &&
1481
+ !(d.containers?.length ?? 0) &&
1482
+ devOpContainerNames(d.repoPath).length === 0
1483
+ );
1484
+ }
1485
+
1486
+ /**
1487
+ * The daemon record for `resolved`, settled: a record from before
1488
+ * 0.3.0-dev.37 (no process group, nothing of its own to verify) is adopted
1489
+ * or dropped here, before any caller decides on it (0.3.0-dev.39). Every
1490
+ * up, restart, down and healthy-skip reads its record through this.
1491
+ */
1492
+ export function settledDaemonRecord(
1493
+ resolved: ResolvedService,
1494
+ workspace: string,
1495
+ onLine: (line: string) => void,
1496
+ ): DaemonRecord | undefined {
1497
+ return settleDaemonRecord(resolved, workspace, onLine).record;
1498
+ }
1499
+
1500
+ /** settledDaemonRecord, with what the settle did (null: nothing to settle). */
1501
+ function settleDaemonRecord(
1502
+ resolved: ResolvedService,
1503
+ workspace: string,
1504
+ onLine: (line: string) => void,
1505
+ ): {
1506
+ record: DaemonRecord | undefined;
1507
+ outcome: "adopted" | "dropped" | "busy" | null;
1508
+ } {
1509
+ const record = findDaemonByAddress(resolved.address);
1510
+ if (!record || !isLegacyRecord(record)) return { record, outcome: null };
1511
+ const outcome = settleLegacyRecord(record, resolved, workspace, onLine);
1512
+ return { record: findDaemonByAddress(resolved.address), outcome };
1513
+ }
1514
+
1515
+ /** The service's known port and configured up command (legacy-adopt.ts). */
1516
+ function serviceProcessFacts(
1517
+ resolved: ResolvedService,
1518
+ workspace: string,
1519
+ ): { port: number | null; upCommand: string | null; plain: boolean } {
1520
+ const manifest = readManifest(resolved.repoPath);
1521
+ const svc = manifest?.services?.[resolved.subsystem] as
1522
+ | { port?: unknown; health?: { local?: { url?: string } } }
1523
+ | undefined;
1524
+ const upDef = manifest
1525
+ ? effectiveLocalCommands(
1526
+ manifest,
1527
+ resolved.subsystem,
1528
+ resolved.repoName,
1529
+ workspace,
1530
+ )["up"]
1531
+ : undefined;
1532
+ const upCommand = upDef ? commandDefToString(upDef) : null;
1533
+ const port = servicePort({
1534
+ port: svc?.port,
1535
+ healthUrl: svc?.health?.local?.url ?? null,
1536
+ upCommand,
1537
+ });
1538
+ // A plain process: an up of its own, no compose stack and no dev op. A
1539
+ // container's port is held by the container runtime, never by the repo.
1540
+ const plain =
1541
+ !!upCommand &&
1542
+ !manifest?.compose &&
1543
+ devOpContainerNames(resolved.repoPath).length === 0;
1544
+ return { port, upCommand, plain };
1545
+ }
1546
+
1547
+ /**
1548
+ * A service with no record whose port is already taken (a plain process
1549
+ * service only): a listener whose cwd is in the repo is the service,
1550
+ * adopted with its process group ("adopted"); one from elsewhere is
1551
+ * reported and left alone ("busy"). Null when the port is free or unknown.
1552
+ */
1553
+ function settleUntrackedListener(
1554
+ resolved: ResolvedService,
1555
+ workspace: string,
1556
+ onLine: (line: string) => void,
1557
+ /** What the caller does about a busy port, said after the warning. */
1558
+ then: string,
1559
+ ): "adopted" | "busy" | null {
1560
+ const facts = serviceProcessFacts(resolved, workspace);
1561
+ if (!facts.plain || !facts.port) return null;
1562
+ const verdict = adoptLegacyProcess({
1563
+ repoPath: resolved.repoPath,
1564
+ port: facts.port,
1565
+ upCommand: null,
1566
+ });
1567
+ if (verdict.kind === "adopted") {
1568
+ registerDaemon({
1569
+ address: resolved.address,
1570
+ repoName: resolved.repoName,
1571
+ subsystem: resolved.subsystem,
1572
+ repoPath: resolved.repoPath,
1573
+ branch: resolved.branch,
1574
+ workspaces: [workspace],
1575
+ logPath: getServiceLogPath(resolved.repoName, resolved.subsystem),
1576
+ startedAt: new Date(),
1577
+ containers: [],
1578
+ });
1579
+ updateDaemonProcessGroup(resolved.address, verdict.group);
1580
+ onLine(
1581
+ `adopted the running process (pid ${verdict.pid}, port ${facts.port}) for ${resolved.address}: it runs in ${resolved.repoPath}`,
1582
+ );
1583
+ return "adopted";
1584
+ }
1585
+ if (!verdict.busy) return null;
1586
+ onLine(
1587
+ `Warning: ${busyListenerText(verdict.busy, resolved.repoPath, resolved.address)}; ${then}`,
1588
+ );
1589
+ return "busy";
1590
+ }
1591
+
1592
+ /**
1593
+ * What a record from before 0.3.0-dev.37 means now (legacy-adopt.ts), for
1594
+ * `up` and `down` alike: "adopted" (the running process is the service; its
1595
+ * group is written into the record, so it is `down`'s to stop) or "dropped"
1596
+ * (stale: warned, the record removed, nothing signalled). The port is the
1597
+ * service's known one (servicePort: `port`, its health URL, `--port` in its
1598
+ * up command); the token match uses its configured up command.
1599
+ */
1600
+ function settleLegacyRecord(
1601
+ record: DaemonRecord,
1602
+ resolved: ResolvedService,
1603
+ workspace: string,
1604
+ onLine: (line: string) => void,
1605
+ ): "adopted" | "dropped" | "busy" {
1606
+ const { port, upCommand } = serviceProcessFacts(resolved, workspace);
1607
+ const verdict = adoptLegacyProcess({
1608
+ repoPath: record.repoPath,
1609
+ pid: typeof record.pid === "number" ? record.pid : undefined,
1610
+ port,
1611
+ upCommand,
1612
+ });
1613
+ if (verdict.kind === "adopted") {
1614
+ updateDaemonProcessGroup(record.address, verdict.group);
1615
+ onLine(
1616
+ `adopted pre-dev.37 process (pid ${verdict.pid}` +
1617
+ (verdict.port ? `, port ${verdict.port}` : "") +
1618
+ `) for ${record.address}`,
1619
+ );
1620
+ return "adopted";
1621
+ }
1622
+ onLine(
1623
+ `Warning: ${record.address} was recorded before 0.3.0-dev.37 and ` +
1624
+ (verdict.busy
1625
+ ? "its port is held by a process that is not its up"
1626
+ : `nothing running is it (no process on its port in ${record.repoPath}` +
1627
+ (typeof record.pid === "number"
1628
+ ? `, pid ${record.pid} not its up`
1629
+ : "") +
1630
+ ")") +
1631
+ "; dropped the record, nothing was signalled",
1632
+ );
1633
+ if (verdict.busy)
1634
+ onLine(
1635
+ `Warning: ${busyListenerText(verdict.busy, record.repoPath, record.address)}`,
1636
+ );
1637
+ unregisterDaemon(record.address);
1638
+ return verdict.busy ? "busy" : "dropped";
1639
+ }
1640
+
1641
+ /**
1642
+ * A restart with no `restart` of its own: the service's down (its declared
1643
+ * down, then the recorded group, as `down` does; forced, since a restart
1644
+ * brings it back for every owner), then its up, detached, as `up` does. The
1645
+ * returned proc stands for the up's streaming tail: killing it stops the
1646
+ * tail (now, or as soon as the up starts), never the service.
1647
+ */
1648
+ function restartAsDownThenUp(
1649
+ options: RunCommandOptions,
1650
+ logPath: string | undefined,
1651
+ ): RunCommandResult {
1652
+ let tail: ChildProcess | null = null;
1653
+ let stopTail = false;
1654
+ const proxy = new EventEmitter() as unknown as ChildProcess;
1655
+ (proxy as unknown as { kill: (s?: NodeJS.Signals) => boolean }).kill = (
1656
+ signal,
1657
+ ) => {
1658
+ stopTail = true;
1659
+ try {
1660
+ tail?.kill?.(signal);
1661
+ } catch {
1662
+ /* already gone */
1663
+ }
1664
+ return true;
1665
+ };
1666
+ runServiceCommand({
1667
+ ...options,
1668
+ command: "down",
1669
+ force: true,
1670
+ onDownOutcome: undefined,
1671
+ onServiceExit: undefined,
1672
+ onClose: (code) => {
1673
+ if (code !== 0) {
1674
+ options.onClose(code);
1675
+ return;
1676
+ }
1677
+ const up = runServiceCommand({ ...options, command: "up" });
1678
+ tail = up.proc;
1679
+ if (stopTail) {
1680
+ try {
1681
+ tail?.kill?.("SIGTERM");
1682
+ } catch {
1683
+ /* already gone */
1684
+ }
1685
+ }
1686
+ },
1687
+ });
1688
+ return { proc: proxy, detachable: true, interactive: false, logPath };
1178
1689
  }
1179
1690
 
1180
1691
  export interface RunCommandResult {
@@ -1216,6 +1727,18 @@ function repoStartedFallback(
1216
1727
  if (!repoEntry || repoEntry.subsystem === resolved.subsystem) return null;
1217
1728
  const manifest = readManifest(resolved.repoPath);
1218
1729
  if (!manifest) return null;
1730
+ // A service with an up of its own starts itself, so its down is its own
1731
+ // too: the process group that up recorded (0.3.0-dev.39, a consumer's
1732
+ // dev.38 finding: `down repo/api` pointed at the repo's down instead).
1733
+ if (
1734
+ effectiveLocalCommands(
1735
+ manifest,
1736
+ resolved.subsystem,
1737
+ resolved.repoName,
1738
+ workspace ?? "default",
1739
+ )["up"]
1740
+ )
1741
+ return null;
1219
1742
  const repoCommands = effectiveLocalCommands(
1220
1743
  manifest,
1221
1744
  repoEntry.subsystem,
@@ -1345,11 +1868,64 @@ export async function prepareServiceSecretEnv(
1345
1868
 
1346
1869
  /** What a run of `command` on `address` would do, without running it. */
1347
1870
  export type ServiceCommandPreview =
1348
- | { kind: "run"; address: string }
1871
+ | { kind: "run"; address: string; note?: string }
1349
1872
  | { kind: "via-repo"; address: string; note: string }
1350
1873
  | { kind: "nothing"; address: string; note: string }
1351
1874
  | { kind: "error"; address: string; note: string };
1352
1875
 
1876
+ /**
1877
+ * What a real `up` of a service with an up of its own does first about its
1878
+ * record and port (settleDaemonRecord, settleUntrackedListener), said for a
1879
+ * dry run (0.3.0-dev.40, a consumer's dev.39 nit: it said only "Would up").
1880
+ * It only looks: nothing is signalled and the record is not written. Null
1881
+ * when there is nothing to settle (the up just runs). Only for a dry run's
1882
+ * words: up-targets reads previewServiceCommand, which this leaves alone.
1883
+ */
1884
+ export function previewUpSettle(
1885
+ address: string,
1886
+ workspace?: string,
1887
+ ): ServiceCommandPreview | null {
1888
+ const resolved = resolveService(address, workspace);
1889
+ if (!resolved) return null;
1890
+ const record = findDaemonByAddress(resolved.address);
1891
+ const legacy = !!record && isLegacyRecord(record);
1892
+ if (record && !legacy) return null;
1893
+ const facts = serviceProcessFacts(resolved, workspace ?? "default");
1894
+ if (!facts.upCommand) return null;
1895
+ if (!legacy && (!facts.plain || !facts.port)) return null;
1896
+ const verdict = adoptLegacyProcess({
1897
+ repoPath: resolved.repoPath,
1898
+ pid: legacy && typeof record.pid === "number" ? record.pid : undefined,
1899
+ port: facts.port,
1900
+ upCommand: legacy ? facts.upCommand : null,
1901
+ });
1902
+ const what = legacy ? "a pre-dev.37 record" : "no record";
1903
+ if (verdict.kind === "adopted")
1904
+ return {
1905
+ kind: "nothing",
1906
+ address,
1907
+ note:
1908
+ `${resolved.address} (${what}): would adopt pid ${verdict.pid}` +
1909
+ (verdict.port ? ` (port ${verdict.port})` : "") +
1910
+ ", nothing started",
1911
+ };
1912
+ if (verdict.busy)
1913
+ return {
1914
+ kind: "error",
1915
+ address,
1916
+ note:
1917
+ verdict.busy.reason === "outside"
1918
+ ? `${resolved.address} (${what}): port ${verdict.busy.port} held outside the repo: would not start`
1919
+ : `${resolved.address} (${what}): port ${verdict.busy.port} held by pid ${verdict.busy.pid} in Den's own process group: would not start`,
1920
+ };
1921
+ if (!legacy) return null;
1922
+ return {
1923
+ kind: "run",
1924
+ address,
1925
+ note: `${resolved.address} (${what}): would drop the record (nothing running), then start it`,
1926
+ };
1927
+ }
1928
+
1353
1929
  /**
1354
1930
  * The dry-run twin of runServiceCommand's command lookup: the same
1355
1931
  * resolution, own-command, restart-synthesis and repo-started rules, so a
@@ -1382,7 +1958,7 @@ export function previewServiceCommand(
1382
1958
  workspace ?? "default",
1383
1959
  )
1384
1960
  : {};
1385
- if (own[command] || (command === "restart" && own["down"] && own["up"]))
1961
+ if (own[command] || (command === "restart" && own["up"]))
1386
1962
  return { kind: "run", address };
1387
1963
  const viaRepo = repoStartedFallback(resolved, command, workspace);
1388
1964
  if (viaRepo?.address)
@@ -1391,12 +1967,22 @@ export function previewServiceCommand(
1391
1967
  return command === "down"
1392
1968
  ? { kind: "nothing", address, note: viaRepo.note }
1393
1969
  : { kind: "error", address, note: viaRepo.note };
1394
- if (command === "down")
1970
+ if (command === "down") {
1971
+ // The real down stops the process group a detached up recorded.
1972
+ const record = findDaemonByAddress(resolved.address);
1973
+ if (hasProcessGroup(record)) return { kind: "run", address };
1974
+ // A repo-level down stops its services' records.
1975
+ if (
1976
+ !address.includes("/") &&
1977
+ repoServiceRecords(resolved.repoName, resolved.address).length > 0
1978
+ )
1979
+ return { kind: "run", address };
1395
1980
  return {
1396
1981
  kind: "nothing",
1397
1982
  address,
1398
1983
  note: "no down command: nothing to stop",
1399
1984
  };
1985
+ }
1400
1986
  return {
1401
1987
  kind: "error",
1402
1988
  address,
@@ -1467,6 +2053,68 @@ export function runServiceCommand(
1467
2053
 
1468
2054
  const { repoPath, subsystem } = resolved;
1469
2055
 
2056
+ const explicit = options.explicit ?? true;
2057
+ const reportDown = (outcome: DownOutcome): void =>
2058
+ options.onDownOutcome?.(outcome);
2059
+ // A down that could not stop what it knows is running: always a warning,
2060
+ // and a failure when the caller named this service.
2061
+ const unstoppableExit = (): number => (explicit ? 1 : 0);
2062
+
2063
+ /**
2064
+ * Down with no down command and no container to stop (0.3.0-dev.37): stop
2065
+ * the process group a detached `up` recorded. With no record there is
2066
+ * nothing to stop. A record without a group (written before dev.37, or by
2067
+ * an adoption) is running as far as Den knows, and Den cannot stop it:
2068
+ * that is said and fails an explicit down instead of reporting a success.
2069
+ * Such a record is dropped, since Den can neither stop nor verify it.
2070
+ */
2071
+ const downWithoutCommand = (why: string): RunCommandResult => {
2072
+ // A repo-level down (`down <repo>`, and `down -a`, which downs each
2073
+ // repo) with no down of its own stops every service record of the repo
2074
+ // through the same per-record path (0.3.0-dev.38): the mirror of `up`
2075
+ // starting a repo's services (resolveUpTargets).
2076
+ const serviceRecords = address.includes("/")
2077
+ ? []
2078
+ : repoServiceRecords(resolved.repoName, resolved.address);
2079
+ if (serviceRecords.length > 0) {
2080
+ void downEach(
2081
+ serviceRecords.map((d) => d.address),
2082
+ options,
2083
+ ).then(({ outcome, exitCode }) => {
2084
+ const own = findDaemonByAddress(resolved.address);
2085
+ if (own) unregisterDaemon(own.address);
2086
+ reportDown(outcome);
2087
+ onClose(exitCode);
2088
+ });
2089
+ return dummy();
2090
+ }
2091
+ // A record from before 0.3.0-dev.37: adopt what runs, or drop it.
2092
+ const record = settledDaemonRecord(resolved, workspace, onLine);
2093
+ if (hasProcessGroup(record)) {
2094
+ void stopDaemonProcessGroup(record, onLine).then((outcome) => {
2095
+ reportDown(outcome);
2096
+ onClose(outcome === "unstoppable" ? unstoppableExit() : 0);
2097
+ });
2098
+ return dummy();
2099
+ }
2100
+ if (record && !isDaemonVerifiablyDead(record)) {
2101
+ onLine(
2102
+ `Warning: ${record.address} is recorded as running, but ${product().cli} kept no process for it ` +
2103
+ `(started before 0.3.0-dev.37) and it has no down command; ` +
2104
+ `stop it yourself (${product().cli} has dropped its record)`,
2105
+ );
2106
+ unregisterDaemon(record.address);
2107
+ reportDown("unstoppable");
2108
+ closeAsync(unstoppableExit());
2109
+ return dummy();
2110
+ }
2111
+ if (record) unregisterDaemon(record.address);
2112
+ onLine(`nothing to stop (${why})`);
2113
+ reportDown("nothing-recorded");
2114
+ closeAsync(0);
2115
+ return dummy();
2116
+ };
2117
+
1470
2118
  // R13: Den never runs a command on a remote service. A down stops nothing
1471
2119
  // here, which is a success; anything else is refused.
1472
2120
  const refusal = placementRefusal(
@@ -1481,6 +2129,22 @@ export function runServiceCommand(
1481
2129
  return dummy();
1482
2130
  }
1483
2131
 
2132
+ // Every up decision below reads the service's record: one from before
2133
+ // 0.3.0-dev.37 is settled (adopted or dropped, and said) first
2134
+ // (0.3.0-dev.39, a consumer's dev.38 finding). A restart settles in its
2135
+ // down step; a down after its ownership gate, which signals nothing.
2136
+ const upSettle =
2137
+ command === "up" ? settleDaemonRecord(resolved, workspace, onLine) : null;
2138
+ // Dropped with its port held by a process Den won't adopt (said by the
2139
+ // settle): refused before anything is spawned, as a repo-level up refuses
2140
+ // it (0.3.0-dev.40, a consumer's dev.39 finding: the start died on
2141
+ // EADDRINUSE first).
2142
+ if (upSettle?.outcome === "busy") {
2143
+ onLine(`${resolved.address} not started: its port is in use`);
2144
+ closeAsync(1);
2145
+ return dummy();
2146
+ }
2147
+
1484
2148
  // ── Up: share-if-identical / hard-block on conflict ─────────────────────────
1485
2149
  if (command === "up" || command === "restart") {
1486
2150
  let existing = findDaemonByAddress(resolved.address);
@@ -1541,6 +2205,25 @@ export function runServiceCommand(
1541
2205
  closeAsync(0);
1542
2206
  return dummy();
1543
2207
  }
2208
+ } else if (command === "up" && !upSettle?.outcome) {
2209
+ // Nothing recorded, but the port is taken (0.3.0-dev.39). In the
2210
+ // repo: that is the service, adopted, already up. Elsewhere: said
2211
+ // and left alone, and refused before anything is spawned
2212
+ // (0.3.0-dev.40), as a repo-level up refuses it.
2213
+ const holder = settleUntrackedListener(
2214
+ resolved,
2215
+ workspace,
2216
+ onLine,
2217
+ `${resolved.address} not started`,
2218
+ );
2219
+ if (holder === "adopted") {
2220
+ closeAsync(0);
2221
+ return dummy();
2222
+ }
2223
+ if (holder === "busy") {
2224
+ closeAsync(1);
2225
+ return dummy();
2226
+ }
1544
2227
  }
1545
2228
  }
1546
2229
  }
@@ -1568,6 +2251,9 @@ export function runServiceCommand(
1568
2251
  return dummy();
1569
2252
  }
1570
2253
  }
2254
+ // Past the ownership gate, a down stops what it finds: a pre-dev.37 record
2255
+ // is settled first, so its adopted group is stopped (0.3.0-dev.39).
2256
+ if (command === "down") settledDaemonRecord(resolved, workspace, onLine);
1571
2257
 
1572
2258
  // Down via opctl: stop the service's running containers directly, identified
1573
2259
  // from live `opctl container ls` ∪ docker `opctl_<base>_<hash>` names — no
@@ -1654,11 +2340,9 @@ export function runServiceCommand(
1654
2340
  // there is nothing to stop — success, same contract as the declared-down
1655
2341
  // fallthrough below.
1656
2342
  if (command === "down") {
1657
- onLine(
1658
- `nothing to stop (no matching opctl containers; no manifest at ${repoPath})`,
2343
+ return downWithoutCommand(
2344
+ `no matching opctl containers; no manifest at ${repoPath}`,
1659
2345
  );
1660
- closeAsync(0);
1661
- return dummy();
1662
2346
  }
1663
2347
  onLine(`Error: could not read ${product().manifestYml} in ${repoPath}`);
1664
2348
  closeAsync(1);
@@ -1679,6 +2363,17 @@ export function runServiceCommand(
1679
2363
  if (!cmdDef && command === "restart") {
1680
2364
  const downDef = svcCommands["down"];
1681
2365
  const upDef = svcCommands["up"];
2366
+ // A daemon restart is a down step, then a detached up step, never one
2367
+ // `sh -c "<down> && <up>"`: the up's recorded process group must hold
2368
+ // only the up (0.3.0-dev.38). The tab runtime composes its own below.
2369
+ // With no down of its own (0.3.0-dev.39, a consumer's dev.38 finding),
2370
+ // the down step stops what the detached up recorded, as `down` does.
2371
+ if (upDef && (!downDef || runtime !== "tab")) {
2372
+ return restartAsDownThenUp(
2373
+ options,
2374
+ getServiceLogPath(resolved.repoName, resolved.subsystem),
2375
+ );
2376
+ }
1682
2377
  if (downDef && upDef) {
1683
2378
  const downCmd = rewriteLegacyOpctlKillCommand(
1684
2379
  "down",
@@ -1706,9 +2401,7 @@ export function runServiceCommand(
1706
2401
  // successful down, not an error (keeps the ⋮ menu / workspace Down safe to
1707
2402
  // offer on every system).
1708
2403
  if (command === "down") {
1709
- onLine("nothing to stop (no running opctl containers, no down command)");
1710
- closeAsync(0);
1711
- return dummy();
2404
+ return downWithoutCommand("no running opctl containers, no down command");
1712
2405
  }
1713
2406
  onLine(
1714
2407
  `Error: command "${command}" not found for service "${address}" (subsystem "${subsystem}") in local environment`,
@@ -1964,6 +2657,17 @@ export function runServiceCommand(
1964
2657
  // "nothing left to stop" is an absence of evidence, not evidence the
1965
2658
  // teardown worked — reporting ✔ there would hide every real failure.
1966
2659
  const effective = exitCode === 0 || sweptClean ? 0 : exitCode;
2660
+ // The down command ran; what the detached up started may still run
2661
+ // (the down stopped its containers, not the process that started
2662
+ // them). Stop the recorded group too (0.3.0-dev.37).
2663
+ const record = findDaemonByAddress(resolved.address);
2664
+ if (effective === 0 && hasProcessGroup(record)) {
2665
+ void stopDaemonProcessGroup(record, onLine).then((outcome) => {
2666
+ closeExistingTab();
2667
+ onClose(outcome === "unstoppable" ? unstoppableExit() : 0);
2668
+ });
2669
+ return;
2670
+ }
1967
2671
  if (effective === 0) {
1968
2672
  unregisterDaemon(resolved.address);
1969
2673
  closeExistingTab();
@@ -1983,17 +2687,59 @@ export function runServiceCommand(
1983
2687
  onClose(exitCode);
1984
2688
  };
1985
2689
 
1986
- const proc = spawnStreaming(
1987
- cmdString,
1988
- [],
1989
- { ...process.env, ...runEnv },
1990
- onLine,
1991
- wrappedOnClose,
1992
- repoPath,
1993
- detachable,
1994
- logPath,
1995
- onServiceExit,
1996
- );
2690
+ // A detached up records its process group, so `down` can stop it.
2691
+ let recorded: RecordedProcessGroup | null = null;
2692
+ const recordGroup =
2693
+ detachable && logPath && command !== "down"
2694
+ ? (pid: number) => {
2695
+ recorded = recordProcessGroup(pid);
2696
+ if (recorded) updateDaemonProcessGroup(resolved.address, recorded);
2697
+ }
2698
+ : null;
2699
+ // A detached up whose whole group is gone when its leader exits leaves
2700
+ // nothing to stop: never keep its pgid (0.3.0-dev.39, a consumer's dev.38
2701
+ // finding: a second server that died on EADDRINUSE was recorded, and a
2702
+ // later down signalled the dead group while the real listener ran on).
2703
+ // A failed start keeps no record at all.
2704
+ const serviceExited = (exitCode: number): void => {
2705
+ const rec = recorded;
2706
+ if (rec && processGroupStateSync(rec)?.state === "gone") {
2707
+ const now = findDaemonByAddress(resolved.address);
2708
+ // A non-zero exit is a failed start: nothing of it runs, so no
2709
+ // record (its containers, if any, still run: then only the group
2710
+ // goes). A zero exit handed off (`make up`): its group stays, and
2711
+ // down says it already exited.
2712
+ if (now?.pgid === rec.pgid && exitCode !== 0) {
2713
+ if (!(now.containers?.length ?? 0)) unregisterDaemon(resolved.address);
2714
+ else clearDaemonProcessGroup(resolved.address);
2715
+ }
2716
+ }
2717
+ onServiceExit?.(exitCode);
2718
+ };
2719
+ const proc = recordGroup
2720
+ ? spawnStreaming(
2721
+ cmdString,
2722
+ [],
2723
+ { ...process.env, ...runEnv },
2724
+ onLine,
2725
+ wrappedOnClose,
2726
+ repoPath,
2727
+ detachable,
2728
+ logPath,
2729
+ serviceExited,
2730
+ recordGroup,
2731
+ )
2732
+ : spawnStreaming(
2733
+ cmdString,
2734
+ [],
2735
+ { ...process.env, ...runEnv },
2736
+ onLine,
2737
+ wrappedOnClose,
2738
+ repoPath,
2739
+ detachable,
2740
+ logPath,
2741
+ onServiceExit,
2742
+ );
1997
2743
  return { proc, detachable, interactive: false, logPath };
1998
2744
  }
1999
2745