@monster-orange/den 0.0.0 → 0.2.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/CHANGELOG.md +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +2185 -0
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Runner
|
|
3
|
+
*
|
|
4
|
+
* Resolves a service address → repo path → reads .den.yml →
|
|
5
|
+
* executes commands (up / down / restart / any named command) with
|
|
6
|
+
* streaming output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { hasRuntime, repoKind } from "../repo-kind.js";
|
|
10
|
+
import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
14
|
+
import {
|
|
15
|
+
spawnStreaming,
|
|
16
|
+
spawnInteractive,
|
|
17
|
+
getServiceLogPath,
|
|
18
|
+
} from "../process/spawn-streaming.js";
|
|
19
|
+
import { isProductionEnv, readUserConfig } from "../den-config.js";
|
|
20
|
+
import { placementRefusal, servicePlacement } from "../manifest/placement.js";
|
|
21
|
+
import { lookupTab, recordTab, forgetTab } from "../cli-tabs.js";
|
|
22
|
+
import {
|
|
23
|
+
closeTab,
|
|
24
|
+
openTab,
|
|
25
|
+
resolveTerminalApp,
|
|
26
|
+
shellQuote,
|
|
27
|
+
} from "../terminal/tabs.js";
|
|
28
|
+
import { readCatalog } from "./catalog.js";
|
|
29
|
+
import { buildServiceEnv } from "./service-env.js";
|
|
30
|
+
import {
|
|
31
|
+
devOpContainerNames,
|
|
32
|
+
diffSpawnedContainers,
|
|
33
|
+
forceStopOpctlContainers,
|
|
34
|
+
inspectOpctlContainer,
|
|
35
|
+
isOpctlAvailable,
|
|
36
|
+
listRunningOpctlContainers,
|
|
37
|
+
} from "./opctl.js";
|
|
38
|
+
import {
|
|
39
|
+
getActiveWorkspace,
|
|
40
|
+
getActiveWorkspaces,
|
|
41
|
+
getFocusedWorkspaceName,
|
|
42
|
+
getWorkspaceByName,
|
|
43
|
+
resolveServiceSource,
|
|
44
|
+
listWorkspaceServices,
|
|
45
|
+
} from "./workspaces.js";
|
|
46
|
+
import {
|
|
47
|
+
checkServiceHealth,
|
|
48
|
+
checkCatalogHealth,
|
|
49
|
+
type ServiceHealthResult,
|
|
50
|
+
} from "./health.js";
|
|
51
|
+
import { EventEmitter } from "events";
|
|
52
|
+
import type { CommandDef, ResolvedService } from "../types/index.js";
|
|
53
|
+
import type { ChildProcess } from "child_process";
|
|
54
|
+
import { getStateDir } from "../paths.js";
|
|
55
|
+
|
|
56
|
+
import { product } from "../product.js";
|
|
57
|
+
// Re-export so existing callers don't need to change their import path.
|
|
58
|
+
export type { ServiceHealthResult };
|
|
59
|
+
export { checkServiceHealth, checkCatalogHealth };
|
|
60
|
+
|
|
61
|
+
// ── Daemon registry ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface DaemonRecord {
|
|
64
|
+
address: string;
|
|
65
|
+
repoName: string;
|
|
66
|
+
subsystem: string;
|
|
67
|
+
repoPath: string;
|
|
68
|
+
logPath: string;
|
|
69
|
+
startedAt: Date;
|
|
70
|
+
/**
|
|
71
|
+
* Branch identity for share/conflict checks. Null/undefined means
|
|
72
|
+
* "unspecified" (typical for dev-managed checkouts).
|
|
73
|
+
*/
|
|
74
|
+
branch?: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Active workspaces that currently own this running instance (refcount).
|
|
77
|
+
* Empty/missing on legacy records — filled on first attach/up.
|
|
78
|
+
*/
|
|
79
|
+
workspaces?: string[];
|
|
80
|
+
/**
|
|
81
|
+
* opctl container names this service spawned, captured at `up` time. Used to
|
|
82
|
+
* shut the service down via `opctl container down` without a declared down
|
|
83
|
+
* command. Optional for backward-compat with records written before this.
|
|
84
|
+
*/
|
|
85
|
+
containers?: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ServiceConflictReason = "identity_mismatch" | "orphan_containers";
|
|
89
|
+
|
|
90
|
+
export class ServiceConflictError extends Error {
|
|
91
|
+
readonly reason: ServiceConflictReason;
|
|
92
|
+
readonly address: string;
|
|
93
|
+
readonly owners: string[];
|
|
94
|
+
readonly running: { repoPath: string; branch: string | null };
|
|
95
|
+
readonly requested: { repoPath: string; branch: string | null };
|
|
96
|
+
readonly orphanContainers?: string[];
|
|
97
|
+
|
|
98
|
+
constructor(opts: {
|
|
99
|
+
reason: ServiceConflictReason;
|
|
100
|
+
address: string;
|
|
101
|
+
owners: string[];
|
|
102
|
+
running: { repoPath: string; branch: string | null };
|
|
103
|
+
requested: { repoPath: string; branch: string | null };
|
|
104
|
+
orphanContainers?: string[];
|
|
105
|
+
message?: string;
|
|
106
|
+
}) {
|
|
107
|
+
const msg =
|
|
108
|
+
opts.message ??
|
|
109
|
+
(opts.reason === "orphan_containers"
|
|
110
|
+
? `Service "${opts.address}" has running opctl containers (${(opts.orphanContainers ?? []).join(", ")}) not tracked by ${product().cli} — refuse to start a second instance`
|
|
111
|
+
: `Service "${opts.address}" is already running for workspace(s) [${opts.owners.join(", ")}] at ${opts.running.repoPath}` +
|
|
112
|
+
` (branch: ${opts.running.branch ?? "n/a"}), which differs from requested ${opts.requested.repoPath}` +
|
|
113
|
+
` (branch: ${opts.requested.branch ?? "n/a"})`);
|
|
114
|
+
super(msg);
|
|
115
|
+
this.name = "ServiceConflictError";
|
|
116
|
+
this.reason = opts.reason;
|
|
117
|
+
this.address = opts.address;
|
|
118
|
+
this.owners = opts.owners;
|
|
119
|
+
this.running = opts.running;
|
|
120
|
+
this.requested = opts.requested;
|
|
121
|
+
this.orphanContainers = opts.orphanContainers;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// JSON representation — startedAt serialises as an ISO string.
|
|
126
|
+
type DaemonRecordJSON = Omit<DaemonRecord, "startedAt"> & { startedAt: string };
|
|
127
|
+
|
|
128
|
+
interface DaemonsFile {
|
|
129
|
+
denPid: number;
|
|
130
|
+
daemons: DaemonRecordJSON[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const _daemonRegistry = new Map<string, DaemonRecord>();
|
|
134
|
+
let daemonRegistryLoadedFrom: string | null = null;
|
|
135
|
+
|
|
136
|
+
function getDaemonsFile(): string {
|
|
137
|
+
return (
|
|
138
|
+
process.env.DEN_DAEMONS_FILE ?? path.join(getStateDir(), "daemons.json")
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeBranch(branch: string | null | undefined): string | null {
|
|
143
|
+
return branch ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function sameDaemonIdentity(
|
|
147
|
+
record: Pick<DaemonRecord, "repoPath" | "branch">,
|
|
148
|
+
repoPath: string,
|
|
149
|
+
branch: string | null | undefined,
|
|
150
|
+
): boolean {
|
|
151
|
+
return (
|
|
152
|
+
record.repoPath === repoPath &&
|
|
153
|
+
normalizeBranch(record.branch) === normalizeBranch(branch)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function toDaemonRecord(json: DaemonRecordJSON): DaemonRecord {
|
|
158
|
+
return {
|
|
159
|
+
...json,
|
|
160
|
+
workspaces: Array.isArray(json.workspaces) ? [...json.workspaces] : [],
|
|
161
|
+
branch: normalizeBranch(json.branch),
|
|
162
|
+
startedAt: new Date(json.startedAt),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readDaemonsFile(filePath: string): Map<string, DaemonRecord> {
|
|
167
|
+
const map = new Map<string, DaemonRecord>();
|
|
168
|
+
try {
|
|
169
|
+
if (!fs.existsSync(filePath)) return map;
|
|
170
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as
|
|
171
|
+
| DaemonsFile
|
|
172
|
+
| DaemonRecordJSON[];
|
|
173
|
+
const records = Array.isArray(parsed) ? parsed : (parsed.daemons ?? []);
|
|
174
|
+
for (const record of records) {
|
|
175
|
+
if (!record.address) continue;
|
|
176
|
+
map.set(record.address, toDaemonRecord(record));
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Corrupt or missing file: keep registry empty.
|
|
180
|
+
}
|
|
181
|
+
return map;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function replaceDaemonRegistry(records: Map<string, DaemonRecord>): void {
|
|
185
|
+
_daemonRegistry.clear();
|
|
186
|
+
for (const [address, record] of records.entries()) {
|
|
187
|
+
_daemonRegistry.set(address, record);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function saveDaemonsFile(filePath: string): void {
|
|
192
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
193
|
+
const records: DaemonRecordJSON[] = Array.from(_daemonRegistry.values()).map(
|
|
194
|
+
(d) => ({ ...d, startedAt: d.startedAt.toISOString() }),
|
|
195
|
+
);
|
|
196
|
+
const file: DaemonsFile = { denPid: process.pid, daemons: records };
|
|
197
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
198
|
+
try {
|
|
199
|
+
fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", "utf-8");
|
|
200
|
+
fs.renameSync(tmp, filePath);
|
|
201
|
+
} catch {
|
|
202
|
+
try {
|
|
203
|
+
fs.unlinkSync(tmp);
|
|
204
|
+
} catch {
|
|
205
|
+
/* ignore cleanup failure */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Populate the in-memory registry from the persisted daemons file.
|
|
212
|
+
* Call once at server startup so daemons started in a previous session
|
|
213
|
+
* are visible to the shutdown prompt.
|
|
214
|
+
*
|
|
215
|
+
* The persisted `denPid` is retained for file compatibility only. It is not
|
|
216
|
+
* a whole-file validity gate: short-lived CLI processes start daemons and exit,
|
|
217
|
+
* while those daemons can still be valid and should remain visible to later CLI
|
|
218
|
+
* calls and the web UI.
|
|
219
|
+
*/
|
|
220
|
+
export function loadDaemonsFromFile(): void {
|
|
221
|
+
const filePath = getDaemonsFile();
|
|
222
|
+
replaceDaemonRegistry(readDaemonsFile(filePath));
|
|
223
|
+
daemonRegistryLoadedFrom = filePath;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function ensureDaemonRegistryLoaded(): void {
|
|
227
|
+
const filePath = getDaemonsFile();
|
|
228
|
+
if (daemonRegistryLoadedFrom === filePath) return;
|
|
229
|
+
loadDaemonsFromFile();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function registerDaemon(record: DaemonRecord): void {
|
|
233
|
+
loadDaemonsFromFile();
|
|
234
|
+
const existing = _daemonRegistry.get(record.address);
|
|
235
|
+
// Owners are only inherited from a record describing the SAME checkout — a
|
|
236
|
+
// registration for a different path/branch replaces the instance, so keeping
|
|
237
|
+
// the old owners would refcount workspaces against something they never
|
|
238
|
+
// started. (`up` blocks on identity mismatch before reaching here; adoption
|
|
239
|
+
// after a lost record is the path that can legitimately differ.)
|
|
240
|
+
const inherited = sameDaemonIdentity(
|
|
241
|
+
existing ?? { repoPath: record.repoPath, branch: record.branch },
|
|
242
|
+
record.repoPath,
|
|
243
|
+
record.branch,
|
|
244
|
+
)
|
|
245
|
+
? (existing?.workspaces ?? [])
|
|
246
|
+
: [];
|
|
247
|
+
const workspaces = [...new Set([...inherited, ...(record.workspaces ?? [])])];
|
|
248
|
+
_daemonRegistry.set(record.address, {
|
|
249
|
+
...record,
|
|
250
|
+
workspaces,
|
|
251
|
+
branch: normalizeBranch(record.branch),
|
|
252
|
+
});
|
|
253
|
+
saveDaemonsFile(getDaemonsFile());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function unregisterDaemon(address: string): void {
|
|
257
|
+
loadDaemonsFromFile();
|
|
258
|
+
_daemonRegistry.delete(address);
|
|
259
|
+
saveDaemonsFile(getDaemonsFile());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Merge observed container base-names into a daemon's record — used to refine
|
|
264
|
+
* the spawn-time seed with the containers a run ACTUALLY created (union, so a
|
|
265
|
+
* partial observation never loses declared candidates). No-op when the daemon
|
|
266
|
+
* has since been unregistered.
|
|
267
|
+
*/
|
|
268
|
+
export function updateDaemonContainers(
|
|
269
|
+
address: string,
|
|
270
|
+
containers: string[],
|
|
271
|
+
): void {
|
|
272
|
+
if (containers.length === 0) return;
|
|
273
|
+
loadDaemonsFromFile();
|
|
274
|
+
const existing = _daemonRegistry.get(address);
|
|
275
|
+
if (!existing) return;
|
|
276
|
+
const merged = [...new Set([...(existing.containers ?? []), ...containers])];
|
|
277
|
+
_daemonRegistry.set(address, { ...existing, containers: merged });
|
|
278
|
+
saveDaemonsFile(getDaemonsFile());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function listDaemons(): DaemonRecord[] {
|
|
282
|
+
loadDaemonsFromFile();
|
|
283
|
+
return Array.from(_daemonRegistry.values());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function findDaemonByAddress(address: string): DaemonRecord | undefined {
|
|
287
|
+
loadDaemonsFromFile();
|
|
288
|
+
return _daemonRegistry.get(address);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Add a workspace owner to a running daemon (share / attach). */
|
|
292
|
+
export function attachDaemonOwner(address: string, workspace: string): void {
|
|
293
|
+
loadDaemonsFromFile();
|
|
294
|
+
const existing = _daemonRegistry.get(address);
|
|
295
|
+
if (!existing) return;
|
|
296
|
+
const workspaces = existing.workspaces ?? [];
|
|
297
|
+
if (!workspaces.includes(workspace)) {
|
|
298
|
+
existing.workspaces = [...workspaces, workspace];
|
|
299
|
+
_daemonRegistry.set(address, existing);
|
|
300
|
+
saveDaemonsFile(getDaemonsFile());
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Remove a workspace owner. Returns true when the daemon was fully
|
|
306
|
+
* unregistered (no owners left); false when other owners remain.
|
|
307
|
+
*/
|
|
308
|
+
export function detachDaemonOwner(
|
|
309
|
+
address: string,
|
|
310
|
+
workspace: string,
|
|
311
|
+
): { removed: boolean; remaining: string[] } {
|
|
312
|
+
loadDaemonsFromFile();
|
|
313
|
+
const existing = _daemonRegistry.get(address);
|
|
314
|
+
if (!existing) return { removed: true, remaining: [] };
|
|
315
|
+
const remaining = (existing.workspaces ?? []).filter((w) => w !== workspace);
|
|
316
|
+
if (remaining.length === 0) {
|
|
317
|
+
_daemonRegistry.delete(address);
|
|
318
|
+
saveDaemonsFile(getDaemonsFile());
|
|
319
|
+
return { removed: true, remaining: [] };
|
|
320
|
+
}
|
|
321
|
+
existing.workspaces = remaining;
|
|
322
|
+
_daemonRegistry.set(address, existing);
|
|
323
|
+
saveDaemonsFile(getDaemonsFile());
|
|
324
|
+
return { removed: false, remaining };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A daemon's owners that are still in the active set.
|
|
329
|
+
*
|
|
330
|
+
* Refcount decisions must never count a workspace that has since been
|
|
331
|
+
* deactivated or deleted: an inactive "owner" can't run `down` for itself
|
|
332
|
+
* (`-w` requires activation), so counting it makes the last real owner detach
|
|
333
|
+
* instead of stop and leaves the service unstoppable without `--force`.
|
|
334
|
+
* `releaseWorkspaceDaemons` keeps the registry honest going forward; this
|
|
335
|
+
* filter also self-heals records written before it existed.
|
|
336
|
+
*/
|
|
337
|
+
export function activeDaemonOwners(
|
|
338
|
+
record: Pick<DaemonRecord, "workspaces"> | undefined,
|
|
339
|
+
active: Set<string> = new Set(getActiveWorkspaces()),
|
|
340
|
+
): string[] {
|
|
341
|
+
return (record?.workspaces ?? []).filter((w) => active.has(w));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Outcome of releasing one daemon during a workspace deactivation. */
|
|
345
|
+
export interface DaemonRelease {
|
|
346
|
+
address: string;
|
|
347
|
+
/** "detached" when other active owners remain, "stopped" when it was downed. */
|
|
348
|
+
outcome: "detached" | "stopped" | "stop-failed";
|
|
349
|
+
/** Active owners still holding the service after a detach. */
|
|
350
|
+
remaining: string[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Release every daemon `workspace` owns (design decision 5: "deactivate
|
|
355
|
+
* detaches ownership and stops only if last owner").
|
|
356
|
+
*
|
|
357
|
+
* Call AFTER the workspace has left `active[]` so the remaining-owner test
|
|
358
|
+
* sees the post-deactivation world. Sole-owner instances are stopped through
|
|
359
|
+
* the normal `down` path with `force` — the refcount question is already
|
|
360
|
+
* settled here, and forcing skips a redundant second ownership check.
|
|
361
|
+
*/
|
|
362
|
+
export async function releaseWorkspaceDaemons(
|
|
363
|
+
workspace: string,
|
|
364
|
+
onLine: (line: string) => void = () => {},
|
|
365
|
+
): Promise<DaemonRelease[]> {
|
|
366
|
+
const owned = listDaemons().filter((d) =>
|
|
367
|
+
(d.workspaces ?? []).includes(workspace),
|
|
368
|
+
);
|
|
369
|
+
const active = new Set(getActiveWorkspaces());
|
|
370
|
+
const releases: DaemonRelease[] = [];
|
|
371
|
+
|
|
372
|
+
for (const daemon of owned) {
|
|
373
|
+
const remaining = activeDaemonOwners(daemon, active).filter(
|
|
374
|
+
(w) => w !== workspace,
|
|
375
|
+
);
|
|
376
|
+
if (remaining.length > 0) {
|
|
377
|
+
detachDaemonOwner(daemon.address, workspace);
|
|
378
|
+
onLine(
|
|
379
|
+
`detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
|
|
380
|
+
);
|
|
381
|
+
releases.push({
|
|
382
|
+
address: daemon.address,
|
|
383
|
+
outcome: "detached",
|
|
384
|
+
remaining,
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
389
|
+
runServiceCommand({
|
|
390
|
+
address: daemon.address,
|
|
391
|
+
command: "down",
|
|
392
|
+
workspace,
|
|
393
|
+
force: true,
|
|
394
|
+
onLine,
|
|
395
|
+
onClose: resolve,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
releases.push({
|
|
399
|
+
address: daemon.address,
|
|
400
|
+
outcome: exitCode === 0 ? "stopped" : "stop-failed",
|
|
401
|
+
remaining: [],
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return releases;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
type ServiceIdentity = Pick<
|
|
409
|
+
ResolvedService,
|
|
410
|
+
"address" | "repoPath" | "branch" | "repoName"
|
|
411
|
+
>;
|
|
412
|
+
|
|
413
|
+
/** Running opctl containers a service declares but den has no record of. */
|
|
414
|
+
function findOrphanContainers(resolved: ServiceIdentity): string[] {
|
|
415
|
+
if (!isOpctlAvailable()) return [];
|
|
416
|
+
const declared = [
|
|
417
|
+
...devOpContainerNames(resolved.repoPath),
|
|
418
|
+
resolved.repoName,
|
|
419
|
+
].filter(Boolean);
|
|
420
|
+
if (declared.length === 0) return [];
|
|
421
|
+
const running = new Set(listRunningOpctlContainers().map((c) => c.base));
|
|
422
|
+
return [...new Set(declared.filter((n) => running.has(n)))];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function mountsRepo(mountSources: string[], repoPath: string): boolean {
|
|
426
|
+
return mountSources.some(
|
|
427
|
+
(source) =>
|
|
428
|
+
source === repoPath || source.startsWith(`${repoPath}${path.sep}`),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Untracked containers that verifiably run `resolved`'s own checkout, decided
|
|
434
|
+
* by bind mounts (see `inspectOpctlContainer`).
|
|
435
|
+
*
|
|
436
|
+
* Den can lose a daemon record while the containers keep running — a web
|
|
437
|
+
* server restart, a crash, a reaper sweep. Without a way back, every later `up`
|
|
438
|
+
* hits the orphan-container block and the only exit is a manual teardown, so
|
|
439
|
+
* these containers are adopted into the registry instead. Containers whose
|
|
440
|
+
* mounts point at a different checkout are NOT adoptable: those are a genuine
|
|
441
|
+
* conflict and must stay blocked.
|
|
442
|
+
*/
|
|
443
|
+
export function findAdoptableContainers(
|
|
444
|
+
resolved: ServiceIdentity,
|
|
445
|
+
orphans: string[] = findOrphanContainers(resolved),
|
|
446
|
+
): { containers: string[]; startedAt: Date | null } {
|
|
447
|
+
const containers: string[] = [];
|
|
448
|
+
let startedAt: Date | null = null;
|
|
449
|
+
for (const base of orphans) {
|
|
450
|
+
const info = inspectOpctlContainer(base);
|
|
451
|
+
// No inspect result — can't claim it and can't blame it; leave it alone.
|
|
452
|
+
if (!info || !mountsRepo(info.mountSources, resolved.repoPath)) continue;
|
|
453
|
+
containers.push(base);
|
|
454
|
+
if (info.startedAt && (!startedAt || info.startedAt < startedAt)) {
|
|
455
|
+
startedAt = info.startedAt;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { containers, startedAt };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Re-register already-running containers as a daemon owned by `workspace`.
|
|
463
|
+
* Returns the adopted container names ([] when there is nothing to adopt).
|
|
464
|
+
*/
|
|
465
|
+
export function adoptRunningContainers(
|
|
466
|
+
resolved: ResolvedService,
|
|
467
|
+
workspace: string,
|
|
468
|
+
): string[] {
|
|
469
|
+
const { containers, startedAt } = findAdoptableContainers(resolved);
|
|
470
|
+
if (containers.length === 0) return [];
|
|
471
|
+
registerDaemon({
|
|
472
|
+
address: resolved.address,
|
|
473
|
+
repoName: resolved.repoName,
|
|
474
|
+
subsystem: resolved.subsystem,
|
|
475
|
+
repoPath: resolved.repoPath,
|
|
476
|
+
branch: resolved.branch,
|
|
477
|
+
workspaces: [workspace],
|
|
478
|
+
logPath: getServiceLogPath(resolved.repoName, resolved.subsystem),
|
|
479
|
+
startedAt: startedAt ?? new Date(),
|
|
480
|
+
containers: [
|
|
481
|
+
...new Set([...containers, ...devOpContainerNames(resolved.repoPath)]),
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
return containers;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Record `workspace` as an owner of a service a caller is about to skip because
|
|
489
|
+
* it already answers its health check.
|
|
490
|
+
*
|
|
491
|
+
* Skipping is right — nothing needs starting — but skipping SILENTLY loses the
|
|
492
|
+
* refcount: the workspace never appears as an owner, so its `down` reports it
|
|
493
|
+
* doesn't own the service, and another owner's `down` takes it away without
|
|
494
|
+
* warning. Returns how ownership was recorded so callers can say so.
|
|
495
|
+
*/
|
|
496
|
+
export function claimHealthyService(
|
|
497
|
+
resolved: ResolvedService,
|
|
498
|
+
workspace: string,
|
|
499
|
+
): "attached" | "adopted" | "untracked" {
|
|
500
|
+
const existing = findDaemonByAddress(resolved.address);
|
|
501
|
+
if (existing) {
|
|
502
|
+
if (!sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)) {
|
|
503
|
+
// Healthy, but the record describes a different checkout — leave the
|
|
504
|
+
// registry alone rather than claim something we can't vouch for.
|
|
505
|
+
return "untracked";
|
|
506
|
+
}
|
|
507
|
+
attachDaemonOwner(resolved.address, workspace);
|
|
508
|
+
return "attached";
|
|
509
|
+
}
|
|
510
|
+
return adoptRunningContainers(resolved, workspace).length > 0
|
|
511
|
+
? "adopted"
|
|
512
|
+
: "untracked";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Check whether starting `resolved` for `workspace` conflicts with a live
|
|
517
|
+
* daemon or orphan opctl containers. Returns null when start/attach is OK;
|
|
518
|
+
* callers should attach when a matching daemon already exists, or adopt when
|
|
519
|
+
* `findAdoptableContainers` reports untracked containers of the same checkout.
|
|
520
|
+
*/
|
|
521
|
+
export function findDaemonConflict(
|
|
522
|
+
resolved: Pick<
|
|
523
|
+
ResolvedService,
|
|
524
|
+
"address" | "repoPath" | "branch" | "repoName"
|
|
525
|
+
>,
|
|
526
|
+
): ServiceConflictError | null {
|
|
527
|
+
loadDaemonsFromFile();
|
|
528
|
+
const existing = _daemonRegistry.get(resolved.address);
|
|
529
|
+
if (existing) {
|
|
530
|
+
if (sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
return new ServiceConflictError({
|
|
534
|
+
reason: "identity_mismatch",
|
|
535
|
+
address: resolved.address,
|
|
536
|
+
owners: existing.workspaces ?? [],
|
|
537
|
+
running: {
|
|
538
|
+
repoPath: existing.repoPath,
|
|
539
|
+
branch: normalizeBranch(existing.branch),
|
|
540
|
+
},
|
|
541
|
+
requested: {
|
|
542
|
+
repoPath: resolved.repoPath,
|
|
543
|
+
branch: normalizeBranch(resolved.branch),
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const orphans = findOrphanContainers(resolved);
|
|
549
|
+
if (orphans.length === 0) return null;
|
|
550
|
+
if (findAdoptableContainers(resolved, orphans).containers.length > 0) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return new ServiceConflictError({
|
|
554
|
+
reason: "orphan_containers",
|
|
555
|
+
address: resolved.address,
|
|
556
|
+
owners: [],
|
|
557
|
+
running: {
|
|
558
|
+
repoPath: resolved.repoPath,
|
|
559
|
+
branch: normalizeBranch(resolved.branch),
|
|
560
|
+
},
|
|
561
|
+
requested: {
|
|
562
|
+
repoPath: resolved.repoPath,
|
|
563
|
+
branch: normalizeBranch(resolved.branch),
|
|
564
|
+
},
|
|
565
|
+
orphanContainers: orphans,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function canResolveDaemonAddress(d: DaemonRecord): boolean {
|
|
570
|
+
if (resolveService(d.address)) return true;
|
|
571
|
+
for (const w of d.workspaces ?? []) {
|
|
572
|
+
if (resolveService(d.address, w)) return true;
|
|
573
|
+
}
|
|
574
|
+
for (const w of getActiveWorkspaces()) {
|
|
575
|
+
if (resolveService(d.address, w)) return true;
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* True when a registered daemon is opctl-based (has candidate container names)
|
|
582
|
+
* and NONE of its candidates are currently running — i.e. its containers died
|
|
583
|
+
* or were killed out from under it.
|
|
584
|
+
*
|
|
585
|
+
* Non-opctl daemons (no discoverable containers) can't be verified this way and
|
|
586
|
+
* are treated as alive, same contract as `pruneDeadDaemons`. Pass `running`
|
|
587
|
+
* when checking many daemons to avoid one `docker ps` per record.
|
|
588
|
+
*/
|
|
589
|
+
export function isDaemonVerifiablyDead(
|
|
590
|
+
d: DaemonRecord,
|
|
591
|
+
running: Set<string> | null = isOpctlAvailable()
|
|
592
|
+
? new Set(listRunningOpctlContainers().map((c) => c.base))
|
|
593
|
+
: null,
|
|
594
|
+
): boolean {
|
|
595
|
+
if (!running) return false; // opctl unavailable — can't verify liveness
|
|
596
|
+
const opctlCandidates = [
|
|
597
|
+
...(d.containers ?? []),
|
|
598
|
+
...devOpContainerNames(d.repoPath),
|
|
599
|
+
].filter(Boolean);
|
|
600
|
+
if (opctlCandidates.length === 0) return false; // non-opctl — trust it
|
|
601
|
+
return ![...opctlCandidates, d.repoName].some((c) => running.has(c));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Remove persisted daemons that are no longer running — called at startup to
|
|
606
|
+
* clear stale entries from previous sessions. Otherwise they reopen as tabs in
|
|
607
|
+
* the web UI and clutter the shutdown prompt.
|
|
608
|
+
*
|
|
609
|
+
* A daemon is dropped when:
|
|
610
|
+
* - its address no longer resolves to a workspace/catalog service, OR
|
|
611
|
+
* - it is opctl-based (has captured containers or a discoverable dev-op) and
|
|
612
|
+
* NONE of its candidate containers are currently running.
|
|
613
|
+
*
|
|
614
|
+
* Daemons with no discoverable opctl containers are KEPT — we can't verify them
|
|
615
|
+
* via `opctl container ls`, so we err toward keeping a possibly-live process
|
|
616
|
+
* (e.g. a non-opctl dev server). Health is deliberately NOT used: a service's
|
|
617
|
+
* shared-infra health check (Postgres/Redis/LocalStack from infra-stack) stays
|
|
618
|
+
* green whether or not the service itself is running. Returns pruned addresses.
|
|
619
|
+
*/
|
|
620
|
+
export function pruneDeadDaemons(): string[] {
|
|
621
|
+
const daemons = listDaemons();
|
|
622
|
+
if (daemons.length === 0) return [];
|
|
623
|
+
const running = isOpctlAvailable()
|
|
624
|
+
? new Set(listRunningOpctlContainers().map((c) => c.base))
|
|
625
|
+
: null;
|
|
626
|
+
const pruned: string[] = [];
|
|
627
|
+
for (const d of daemons) {
|
|
628
|
+
try {
|
|
629
|
+
if (!canResolveDaemonAddress(d)) {
|
|
630
|
+
unregisterDaemon(d.address);
|
|
631
|
+
pruned.push(d.address);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (isDaemonVerifiablyDead(d, running)) {
|
|
635
|
+
unregisterDaemon(d.address);
|
|
636
|
+
pruned.push(d.address);
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
/* keep the entry on any error */
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return pruned;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Service resolution ────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Resolve a service address against a workspace + catalog.
|
|
649
|
+
* When `workspaceName` is omitted, uses the focused workspace.
|
|
650
|
+
* Returns null if the service is not found.
|
|
651
|
+
*/
|
|
652
|
+
export function resolveService(
|
|
653
|
+
address: string,
|
|
654
|
+
workspaceName?: string,
|
|
655
|
+
): ResolvedService | null {
|
|
656
|
+
const ws = workspaceName
|
|
657
|
+
? getWorkspaceByName(workspaceName)
|
|
658
|
+
: getActiveWorkspace();
|
|
659
|
+
if (!ws) return null;
|
|
660
|
+
const catalog = readCatalog();
|
|
661
|
+
|
|
662
|
+
let resolved = resolveServiceSource(address, ws.name, ws.config, catalog);
|
|
663
|
+
|
|
664
|
+
// Bare multi-service repo registered ONLY by subsystem (e.g. "shop/db" +
|
|
665
|
+
// "shop/api" with no bare "shop" entry): resolve via a "repo/subsystem" entry,
|
|
666
|
+
// preferring the manifest entrypoint, so the bare repo name is still runnable
|
|
667
|
+
// (the derived graph + `den up` address repos by bare name).
|
|
668
|
+
if (!resolved && !address.includes("/")) {
|
|
669
|
+
const subAddrs = Object.keys(ws.config.services).filter((a) =>
|
|
670
|
+
a.startsWith(`${address}/`),
|
|
671
|
+
);
|
|
672
|
+
if (subAddrs.length > 0) {
|
|
673
|
+
const first = resolveServiceSource(
|
|
674
|
+
subAddrs[0],
|
|
675
|
+
ws.name,
|
|
676
|
+
ws.config,
|
|
677
|
+
catalog,
|
|
678
|
+
);
|
|
679
|
+
const entry = first
|
|
680
|
+
? readManifest(first.repoPath)?.entrypoint
|
|
681
|
+
: undefined;
|
|
682
|
+
const chosen =
|
|
683
|
+
entry && subAddrs.includes(`${address}/${entry}`)
|
|
684
|
+
? `${address}/${entry}`
|
|
685
|
+
: subAddrs[0];
|
|
686
|
+
resolved = resolveServiceSource(chosen, ws.name, ws.config, catalog);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (!resolved) return null;
|
|
690
|
+
|
|
691
|
+
// Bare repo address correction: when the address is just the repo name, the
|
|
692
|
+
// subsystem defaults to the repo name, which usually isn't a manifest service
|
|
693
|
+
// key. Map it to a runnable subsystem so command lookup
|
|
694
|
+
// (`manifest.services[subsystem]`) hits a real entry:
|
|
695
|
+
// - single-service repo → that one service
|
|
696
|
+
// - multi-service repo → the declared `entrypoint` (e.g. links-api's
|
|
697
|
+
// services are pg/redis/localstack/api, entrypoint
|
|
698
|
+
// api → `make _api-up`). Without this the derived
|
|
699
|
+
// `up` fails with "command up not found".
|
|
700
|
+
// (The listing path, expandMonoRepoEntry, instead fans a bare multi-service
|
|
701
|
+
// entry out to ALL subsystems; here we run the single entrypoint node.)
|
|
702
|
+
if (resolved.subsystem === resolved.repoName) {
|
|
703
|
+
const manifest = readManifest(resolved.repoPath);
|
|
704
|
+
const serviceKeys = Object.keys(manifest?.services ?? {});
|
|
705
|
+
if (!serviceKeys.includes(resolved.subsystem)) {
|
|
706
|
+
if (serviceKeys.length === 1) {
|
|
707
|
+
return { ...resolved, subsystem: serviceKeys[0] };
|
|
708
|
+
}
|
|
709
|
+
if (manifest?.entrypoint && serviceKeys.includes(manifest.entrypoint)) {
|
|
710
|
+
return { ...resolved, subsystem: manifest.entrypoint };
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return resolved;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Return all services in the focused (or named) workspace. */
|
|
719
|
+
export function getWorkspaceServices(
|
|
720
|
+
workspaceName?: string,
|
|
721
|
+
): ResolvedService[] {
|
|
722
|
+
const ws = workspaceName
|
|
723
|
+
? getWorkspaceByName(workspaceName)
|
|
724
|
+
: getActiveWorkspace();
|
|
725
|
+
if (!ws) return [];
|
|
726
|
+
const catalog = readCatalog();
|
|
727
|
+
return listWorkspaceServices(ws.config, catalog, ws.name);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── Command execution ─────────────────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
function commandDefToString(cmdDef: CommandDef): string {
|
|
733
|
+
return typeof cmdDef === "string" ? cmdDef : cmdDef.cmd;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isLegacyOpctlKillCommand(cmdString: string): boolean {
|
|
737
|
+
return /^opctl\s+kill\s*$/.test(cmdString.trim());
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function inferOpctlContainerName(resolved: ResolvedService): string {
|
|
741
|
+
return resolved.repoName || path.basename(resolved.repoPath);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Whether `resolved` speaks for the repo's MAIN container for teardown
|
|
746
|
+
* purposes: a bare repo address, a single-service repo, a repo with no
|
|
747
|
+
* declared entrypoint, or the entrypoint subsystem itself.
|
|
748
|
+
*
|
|
749
|
+
* Auxiliary subsystems (files-api/s3, links-api/pg, …) are thin
|
|
750
|
+
* wrappers around shared infra or one-shot bootstrap ops. By opctl convention
|
|
751
|
+
* the container named exactly for the repo ("files-api") belongs to the
|
|
752
|
+
* entrypoint, so an auxiliary down must never claim it — that is how downing
|
|
753
|
+
* `files-api/s3` from one workspace SIGKILLed the files-api API that
|
|
754
|
+
* another workspace was running.
|
|
755
|
+
*/
|
|
756
|
+
function ownsRepoContainers(
|
|
757
|
+
resolved: Pick<ResolvedService, "address" | "repoPath" | "subsystem">,
|
|
758
|
+
): boolean {
|
|
759
|
+
if (!resolved.address.includes("/")) return true;
|
|
760
|
+
const manifest = readManifest(resolved.repoPath);
|
|
761
|
+
const serviceKeys = Object.keys(manifest?.services ?? {});
|
|
762
|
+
if (serviceKeys.length <= 1) return true;
|
|
763
|
+
const entry = manifest?.entrypoint;
|
|
764
|
+
if (!entry) return true; // no declared entrypoint — keep repo-wide behavior
|
|
765
|
+
return resolved.subsystem === entry;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The container base-names to seed a daemon record with at spawn time.
|
|
770
|
+
*
|
|
771
|
+
* Repo-owning addresses get the full manifest declaration; auxiliary
|
|
772
|
+
* subsystems only get names with affinity to themselves, so an s3/pg/redis
|
|
773
|
+
* record never claims the repo's main container and can never arm a
|
|
774
|
+
* repo-wide kill through the registry candidate set.
|
|
775
|
+
*/
|
|
776
|
+
function spawnContainerCandidates(
|
|
777
|
+
resolved: Pick<
|
|
778
|
+
ResolvedService,
|
|
779
|
+
"address" | "repoPath" | "subsystem" | "repoName"
|
|
780
|
+
>,
|
|
781
|
+
): string[] {
|
|
782
|
+
const declared = devOpContainerNames(resolved.repoPath);
|
|
783
|
+
if (ownsRepoContainers(resolved)) return declared;
|
|
784
|
+
return declared.filter(
|
|
785
|
+
(n) =>
|
|
786
|
+
n === resolved.subsystem ||
|
|
787
|
+
n === `${resolved.repoName}-${resolved.subsystem}` ||
|
|
788
|
+
n.endsWith(`-${resolved.subsystem}`),
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The opctl container base-names to take down for a service: candidate names
|
|
794
|
+
* (captured at spawn, declared in the repo's dev/up op, or the repo name)
|
|
795
|
+
* intersected with what is actually running now (`opctl container ls`).
|
|
796
|
+
*
|
|
797
|
+
* Matching live state means no spawn-time timing guesswork and a natural no-op
|
|
798
|
+
* when nothing is running. Returns [] when opctl is unavailable or nothing
|
|
799
|
+
* matches (so the caller falls back to the declared `down` command — e.g.
|
|
800
|
+
* infra-stack's nested-op containers, or a non-opctl service).
|
|
801
|
+
*
|
|
802
|
+
* Auxiliary subsystems (see `ownsRepoContainers`) never target the repo's main
|
|
803
|
+
* container: their down must not take out the entrypoint daemon that other
|
|
804
|
+
* addresses/workspaces own.
|
|
805
|
+
*/
|
|
806
|
+
function resolveOpctlDownTargets(resolved: ResolvedService): string[] {
|
|
807
|
+
if (!isOpctlAvailable()) return [];
|
|
808
|
+
ensureDaemonRegistryLoaded();
|
|
809
|
+
const repoWide = ownsRepoContainers(resolved);
|
|
810
|
+
const candidates = new Set<string>(
|
|
811
|
+
[
|
|
812
|
+
...(_daemonRegistry.get(resolved.address)?.containers ?? []),
|
|
813
|
+
...devOpContainerNames(resolved.repoPath),
|
|
814
|
+
resolved.repoName,
|
|
815
|
+
].filter(Boolean),
|
|
816
|
+
);
|
|
817
|
+
// A manifest-less resolve keeps the BARE address ("shop"), while daemons are
|
|
818
|
+
// registered under subsystem addresses ("shop/api") — so also take any
|
|
819
|
+
// registry entry whose repo matches. Without this, a fresh checkout (clone
|
|
820
|
+
// not yet created) can never down a service the previous checkout started.
|
|
821
|
+
// Only repo-wide addresses may sweep in sibling records: an auxiliary
|
|
822
|
+
// subsystem must not inherit the entrypoint's containers.
|
|
823
|
+
if (repoWide) {
|
|
824
|
+
for (const [addr, rec] of _daemonRegistry) {
|
|
825
|
+
if (
|
|
826
|
+
addr === resolved.repoName ||
|
|
827
|
+
addr.startsWith(`${resolved.repoName}/`)
|
|
828
|
+
) {
|
|
829
|
+
for (const c of rec.containers ?? []) candidates.add(c);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const targets = new Set<string>();
|
|
834
|
+
// Conventional container naming: a repo's opctl containers are named either
|
|
835
|
+
// exactly for the repo ("files-api") or repo-prefixed
|
|
836
|
+
// ("shop-api" for shop, "jobs-runtime-small"). The prefix rule only
|
|
837
|
+
// claims a base no OTHER catalog repo claims more precisely, so `down shop`
|
|
838
|
+
// can never take shop-web's container. This is what makes `down` work with
|
|
839
|
+
// no manifest and no clone on disk — down is conventional by contract.
|
|
840
|
+
const otherRepos = Object.keys(readCatalog()).filter(
|
|
841
|
+
(r) => r !== resolved.repoName,
|
|
842
|
+
);
|
|
843
|
+
const claimedByOther = (base: string): boolean =>
|
|
844
|
+
otherRepos.some((o) => base === o || base.startsWith(`${o}-`));
|
|
845
|
+
// An auxiliary subsystem only claims bases with NAME AFFINITY to itself
|
|
846
|
+
// ("pg", "shop-postgres", "stack-local-localstack"), never the repo-named or
|
|
847
|
+
// other entrypoint containers its repo-wide candidate set (manifest names,
|
|
848
|
+
// legacy registry records) would otherwise sweep in.
|
|
849
|
+
const subsystemAffine = (base: string): boolean =>
|
|
850
|
+
base === resolved.subsystem ||
|
|
851
|
+
base === `${resolved.repoName}-${resolved.subsystem}` ||
|
|
852
|
+
base.endsWith(`-${resolved.subsystem}`);
|
|
853
|
+
for (const c of listRunningOpctlContainers()) {
|
|
854
|
+
const matched =
|
|
855
|
+
candidates.has(c.base) ||
|
|
856
|
+
c.base === resolved.repoName ||
|
|
857
|
+
(c.base.startsWith(`${resolved.repoName}-`) && !claimedByOther(c.base));
|
|
858
|
+
if (!matched) continue;
|
|
859
|
+
if (!repoWide && !subsystemAffine(c.base)) continue;
|
|
860
|
+
targets.add(c.base);
|
|
861
|
+
}
|
|
862
|
+
return [...targets];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* True when any of the service's opctl containers are currently running —
|
|
867
|
+
* the run-state signal `restart` uses to skip a pointless (and possibly
|
|
868
|
+
* error-prone) down phase on an already-stopped service.
|
|
869
|
+
*/
|
|
870
|
+
export function hasRunningOpctlContainers(resolved: ResolvedService): boolean {
|
|
871
|
+
return resolveOpctlDownTargets(resolved).length > 0;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export function rewriteLegacyOpctlKillCommandForContainer(
|
|
875
|
+
command: string,
|
|
876
|
+
cmdString: string,
|
|
877
|
+
containerName: string,
|
|
878
|
+
onLine?: (line: string) => void,
|
|
879
|
+
): string {
|
|
880
|
+
if (command !== "down" || !isLegacyOpctlKillCommand(cmdString)) {
|
|
881
|
+
return cmdString;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const rewritten = `opctl container delete --label container-name=${shellQuote(containerName)}`;
|
|
885
|
+
onLine?.(
|
|
886
|
+
`legacy "opctl kill" mapped to opctl container delete for ${containerName}`,
|
|
887
|
+
);
|
|
888
|
+
return rewritten;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function rewriteLegacyOpctlKillCommand(
|
|
892
|
+
command: string,
|
|
893
|
+
cmdString: string,
|
|
894
|
+
resolved: ResolvedService,
|
|
895
|
+
onLine?: (line: string) => void,
|
|
896
|
+
): string {
|
|
897
|
+
return rewriteLegacyOpctlKillCommandForContainer(
|
|
898
|
+
command,
|
|
899
|
+
cmdString,
|
|
900
|
+
inferOpctlContainerName(resolved),
|
|
901
|
+
onLine,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function isOpctlContainerDownCommand(cmd: string): boolean {
|
|
906
|
+
return /\bopctl\s+container\s+down\b/i.test(cmd);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Base names from `opctl container down <base> [--force]` (and `;`-chained forms). */
|
|
910
|
+
function opctlDownBasesFromCommand(cmd: string): string[] {
|
|
911
|
+
const bases: string[] = [];
|
|
912
|
+
for (const part of cmd.split(/;|&&/)) {
|
|
913
|
+
const m = part.match(/opctl\s+container\s+down\s+(\S+)/i);
|
|
914
|
+
if (!m) continue;
|
|
915
|
+
bases.push(m[1].replace(/^['"]|['"]$/g, ""));
|
|
916
|
+
}
|
|
917
|
+
return bases;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function shouldDetachServiceCommand(
|
|
921
|
+
command: string,
|
|
922
|
+
cmdDef: CommandDef,
|
|
923
|
+
): boolean {
|
|
924
|
+
return command === "down" || command === "setup"
|
|
925
|
+
? false
|
|
926
|
+
: typeof cmdDef === "string"
|
|
927
|
+
? true
|
|
928
|
+
: // restart ends with a long-running up, so it detaches unless the
|
|
929
|
+
// command says it finishes on its own (`detachable: false`).
|
|
930
|
+
(cmdDef.detachable ?? true);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function isInteractiveServiceCommand(cmdDef: CommandDef): boolean {
|
|
934
|
+
return typeof cmdDef === "string" ? false : (cmdDef.interactive ?? false);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Runtime for a service command: a real terminal tab, or a backgrounded daemon
|
|
939
|
+
* whose output is streamed. Only starting commands can run in a tab — a `down`
|
|
940
|
+
* in a tab would close before you could read it.
|
|
941
|
+
*
|
|
942
|
+
* `DEN_FORCE_TABS` (the CLI's `--tabs` / `--no-tabs`) wins over the
|
|
943
|
+
* `cli_tabs` preference. Shared by the CLI and the web exec routes so the
|
|
944
|
+
* preference means the same thing wherever an up is triggered from.
|
|
945
|
+
*/
|
|
946
|
+
export function resolveServiceRuntime(command: string): "daemon" | "tab" {
|
|
947
|
+
if (command !== "up" && command !== "restart") return "daemon";
|
|
948
|
+
if (process.env.DEN_FORCE_TABS === "1") return "tab";
|
|
949
|
+
if (process.env.DEN_FORCE_TABS === "0") return "daemon";
|
|
950
|
+
return readUserConfig().cli_tabs ? "tab" : "daemon";
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export interface RunCommandOptions {
|
|
954
|
+
/** Service address: "shop/api" or "tasks-runner" */
|
|
955
|
+
address: string;
|
|
956
|
+
/** Command name from .den.yml services.<subsystem>.commands.local: "up", "down", "restart" */
|
|
957
|
+
command: string;
|
|
958
|
+
onLine: (line: string) => void;
|
|
959
|
+
onClose: (exitCode: number) => void;
|
|
960
|
+
env?: NodeJS.ProcessEnv;
|
|
961
|
+
runtime?: "daemon" | "tab";
|
|
962
|
+
/**
|
|
963
|
+
* Workspace to resolve/own this command against. Defaults to focus.
|
|
964
|
+
* Must be active when provided by CLI/API callers (enforced upstream).
|
|
965
|
+
*/
|
|
966
|
+
workspace?: string;
|
|
967
|
+
/**
|
|
968
|
+
* For `down`: stop the instance even when other workspaces still own it.
|
|
969
|
+
*/
|
|
970
|
+
force?: boolean;
|
|
971
|
+
/**
|
|
972
|
+
* Fires when the SERVICE process itself exits (daemon mode). onClose only
|
|
973
|
+
* tracks the streaming tail, so without this a `make up` that dies AFTER
|
|
974
|
+
* the detach window is invisible to fail-fast callers.
|
|
975
|
+
*/
|
|
976
|
+
onServiceExit?: (exitCode: number) => void;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export interface RunCommandResult {
|
|
980
|
+
proc: ChildProcess;
|
|
981
|
+
/** True when the command is expected to run indefinitely (e.g. `up`). */
|
|
982
|
+
detachable: boolean;
|
|
983
|
+
/**
|
|
984
|
+
* True when the command requires interactive terminal access (HITL).
|
|
985
|
+
* The process inherits the parent's stdin/stdout/stderr — no output is
|
|
986
|
+
* streamed via onLine. The caller must suspend any spinner before the
|
|
987
|
+
* command runs.
|
|
988
|
+
*/
|
|
989
|
+
interactive: boolean;
|
|
990
|
+
/**
|
|
991
|
+
* For detachable daemon commands: path to the log file the service is
|
|
992
|
+
* writing to. Callers can surface this so the user knows where to look.
|
|
993
|
+
*/
|
|
994
|
+
logPath?: string;
|
|
995
|
+
tab?: {
|
|
996
|
+
terminalApp: "iterm2" | "terminal";
|
|
997
|
+
tabId: string;
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Resolve and execute a service command.
|
|
1003
|
+
* Returns the spawned child process and whether it is a long-running (detachable) command.
|
|
1004
|
+
* Detachable procs have already been unref()'d — the caller should not await their exit.
|
|
1005
|
+
*/
|
|
1006
|
+
export function runServiceCommand(
|
|
1007
|
+
options: RunCommandOptions,
|
|
1008
|
+
): RunCommandResult {
|
|
1009
|
+
const {
|
|
1010
|
+
address,
|
|
1011
|
+
command,
|
|
1012
|
+
onLine,
|
|
1013
|
+
onClose,
|
|
1014
|
+
env: optionEnv,
|
|
1015
|
+
onServiceExit,
|
|
1016
|
+
force = false,
|
|
1017
|
+
} = options;
|
|
1018
|
+
const runtime = options.runtime ?? "daemon";
|
|
1019
|
+
const workspace = options.workspace ?? getFocusedWorkspaceName() ?? "default";
|
|
1020
|
+
|
|
1021
|
+
// Inject den-resolved shared env (the org config's service_env) under any explicit
|
|
1022
|
+
// per-call env, so consumer repos get correct locations regardless of where
|
|
1023
|
+
// their checkout lives. buildServiceEnv never overrides caller-set values.
|
|
1024
|
+
const sharedEnv = buildServiceEnv();
|
|
1025
|
+
const env: NodeJS.ProcessEnv = { ...sharedEnv, ...optionEnv };
|
|
1026
|
+
|
|
1027
|
+
// The tab variant carries only the den-RESOLVED values, never the caller's
|
|
1028
|
+
// env: `getSudoEnv()`'s PATH points at a private askpass dir that must not
|
|
1029
|
+
// leak into a user's shell, and a tab already has the user's own PATH.
|
|
1030
|
+
const tabEnv: Record<string, string> = {};
|
|
1031
|
+
for (const [key, value] of Object.entries(sharedEnv)) {
|
|
1032
|
+
if (typeof value === "string" && value) tabEnv[key] = value;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const dummy = (): RunCommandResult => ({
|
|
1036
|
+
proc: new EventEmitter() as unknown as ChildProcess,
|
|
1037
|
+
detachable: false,
|
|
1038
|
+
interactive: false,
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// NEVER invoke onClose synchronously (zalgo): callers register the callback
|
|
1042
|
+
// in the same expression that receives our return value, so a sync call
|
|
1043
|
+
// observes their `proc` binding in TDZ — that ReferenceError crashed the
|
|
1044
|
+
// whole web server when workspace Down hit a system with nothing to stop.
|
|
1045
|
+
const closeAsync = (code: number): void => {
|
|
1046
|
+
setImmediate(() => onClose(code));
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
const resolved = resolveService(address, workspace);
|
|
1050
|
+
if (!resolved) {
|
|
1051
|
+
onLine(
|
|
1052
|
+
`Error: service "${address}" not found in catalog or workspace "${workspace}"`,
|
|
1053
|
+
);
|
|
1054
|
+
closeAsync(1);
|
|
1055
|
+
return dummy();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const { repoPath, subsystem } = resolved;
|
|
1059
|
+
|
|
1060
|
+
// R13: Den never runs a command on a remote service. A down stops nothing
|
|
1061
|
+
// here, which is a success; anything else is refused.
|
|
1062
|
+
const refusal = placementRefusal(
|
|
1063
|
+
resolved.address,
|
|
1064
|
+
servicePlacement(readManifest(repoPath), subsystem, "local"),
|
|
1065
|
+
(e) => isProductionEnv(e) === true,
|
|
1066
|
+
command === "down" ? "stop" : "start",
|
|
1067
|
+
);
|
|
1068
|
+
if (refusal) {
|
|
1069
|
+
onLine(refusal);
|
|
1070
|
+
closeAsync(command === "down" ? 0 : 1);
|
|
1071
|
+
return dummy();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ── Up: share-if-identical / hard-block on conflict ─────────────────────────
|
|
1075
|
+
if (command === "up" || command === "restart") {
|
|
1076
|
+
let existing = findDaemonByAddress(resolved.address);
|
|
1077
|
+
// A registry record whose containers all died (killed, crashed, reaped)
|
|
1078
|
+
// must not gate the start: attaching to it would report "already running"
|
|
1079
|
+
// while nothing runs, and a mismatched-identity corpse would raise a
|
|
1080
|
+
// conflict against a daemon that no longer exists. Drop it and start fresh.
|
|
1081
|
+
if (existing && isDaemonVerifiablyDead(existing)) {
|
|
1082
|
+
onLine(
|
|
1083
|
+
`stale daemon record for ${resolved.address} — no running containers; starting fresh`,
|
|
1084
|
+
);
|
|
1085
|
+
unregisterDaemon(resolved.address);
|
|
1086
|
+
existing = undefined;
|
|
1087
|
+
}
|
|
1088
|
+
if (
|
|
1089
|
+
existing &&
|
|
1090
|
+
sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)
|
|
1091
|
+
) {
|
|
1092
|
+
attachDaemonOwner(resolved.address, workspace);
|
|
1093
|
+
const owners = findDaemonByAddress(resolved.address)?.workspaces ?? [
|
|
1094
|
+
workspace,
|
|
1095
|
+
];
|
|
1096
|
+
onLine(
|
|
1097
|
+
`already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
|
|
1098
|
+
);
|
|
1099
|
+
// restart still needs to bounce; only short-circuit pure `up`.
|
|
1100
|
+
if (command === "up") {
|
|
1101
|
+
closeAsync(0);
|
|
1102
|
+
return dummy();
|
|
1103
|
+
}
|
|
1104
|
+
// A restart interrupts every owner, which is exactly what `down`'s
|
|
1105
|
+
// refcount refuses to do silently. Restarting a shared service is still
|
|
1106
|
+
// allowed (it comes back), but the other owners must be named.
|
|
1107
|
+
const coOwners = activeDaemonOwners({ workspaces: owners }).filter(
|
|
1108
|
+
(w) => w !== workspace,
|
|
1109
|
+
);
|
|
1110
|
+
if (coOwners.length > 0) {
|
|
1111
|
+
onLine(
|
|
1112
|
+
`Warning: restarting a shared instance — this interrupts [${coOwners.join(", ")}]`,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
} else {
|
|
1116
|
+
const conflict = findDaemonConflict(resolved);
|
|
1117
|
+
if (conflict) {
|
|
1118
|
+
onLine(`Error: ${conflict.message}`);
|
|
1119
|
+
closeAsync(1);
|
|
1120
|
+
return dummy();
|
|
1121
|
+
}
|
|
1122
|
+
const adopted = adoptRunningContainers(resolved, workspace);
|
|
1123
|
+
if (adopted.length > 0) {
|
|
1124
|
+
onLine(
|
|
1125
|
+
`adopted already-running containers (${adopted.join(", ")}) — owners: [${workspace}] at ${resolved.repoPath}`,
|
|
1126
|
+
);
|
|
1127
|
+
// restart still needs to bounce; only short-circuit pure `up`.
|
|
1128
|
+
if (command === "up") {
|
|
1129
|
+
closeAsync(0);
|
|
1130
|
+
return dummy();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ── Down: refcount detach unless last owner or --force ──────────────────────
|
|
1137
|
+
// Only ACTIVE owners count: a deactivated workspace can't stop the service
|
|
1138
|
+
// itself, so letting it hold a reference would wedge the instance.
|
|
1139
|
+
if (command === "down" && !force) {
|
|
1140
|
+
const existing = findDaemonByAddress(resolved.address);
|
|
1141
|
+
const owners = activeDaemonOwners(existing);
|
|
1142
|
+
if (owners.length > 0 && !owners.includes(workspace)) {
|
|
1143
|
+
onLine(
|
|
1144
|
+
`workspace "${workspace}" does not own running "${resolved.address}" (owners: [${owners.join(", ")}]) — use \`--force\` to stop it anyway`,
|
|
1145
|
+
);
|
|
1146
|
+
closeAsync(0);
|
|
1147
|
+
return dummy();
|
|
1148
|
+
}
|
|
1149
|
+
if (owners.length > 1 && owners.includes(workspace)) {
|
|
1150
|
+
detachDaemonOwner(resolved.address, workspace);
|
|
1151
|
+
const remaining = owners.filter((w) => w !== workspace);
|
|
1152
|
+
onLine(
|
|
1153
|
+
`detached workspace "${workspace}" — still running for [${remaining.join(", ")}]`,
|
|
1154
|
+
);
|
|
1155
|
+
closeAsync(0);
|
|
1156
|
+
return dummy();
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Down via opctl: stop the service's running containers directly, identified
|
|
1161
|
+
// from live `opctl container ls` ∪ docker `opctl_<base>_<hash>` names — no
|
|
1162
|
+
// declared down command (or even a manifest) required. Falls through to the
|
|
1163
|
+
// manifest-defined down only when no opctl container matches (infra-stack's
|
|
1164
|
+
// nested-op containers, non-opctl services, or nothing running).
|
|
1165
|
+
if (command === "down") {
|
|
1166
|
+
const targets = resolveOpctlDownTargets(resolved);
|
|
1167
|
+
if (targets.length > 0) {
|
|
1168
|
+
const downCmd = targets
|
|
1169
|
+
.map((name) => `opctl container down ${shellQuote(name)} --force`)
|
|
1170
|
+
.join("; ");
|
|
1171
|
+
onLine(`opctl container down: ${targets.join(", ")}`);
|
|
1172
|
+
const proc = spawnStreaming(
|
|
1173
|
+
downCmd,
|
|
1174
|
+
[],
|
|
1175
|
+
// spawnStreaming uses this as the COMPLETE child env, so process.env
|
|
1176
|
+
// (notably PATH) must be included or `opctl` isn't found and every
|
|
1177
|
+
// teardown exits 1 — leaving every container running.
|
|
1178
|
+
{ ...process.env, ...env },
|
|
1179
|
+
onLine,
|
|
1180
|
+
(exitCode) => {
|
|
1181
|
+
unregisterDaemon(resolved.address);
|
|
1182
|
+
const existing = lookupTab(resolved.address);
|
|
1183
|
+
if (existing) {
|
|
1184
|
+
try {
|
|
1185
|
+
closeTab(existing.tabId, existing.terminalApp);
|
|
1186
|
+
forgetTab(resolved.address);
|
|
1187
|
+
} catch {
|
|
1188
|
+
/* best-effort tab cleanup */
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// opctl can exit 1 even when the teardown succeeded — judge the
|
|
1192
|
+
// OUTCOME, not the exit code: if none of the service's containers
|
|
1193
|
+
// are running anymore, the down worked. (Everything downstream —
|
|
1194
|
+
// restart's verify gate, the ✓/✗ in the UI, CLI down — keys off
|
|
1195
|
+
// this close code.)
|
|
1196
|
+
//
|
|
1197
|
+
// When containers ARE still running, fall back to `docker rm -f`:
|
|
1198
|
+
// opctl re-elevates with `sudo -E`, which fails on sudoers that grant
|
|
1199
|
+
// NOPASSWD for opctl without SETENV ("sorry, you are not allowed to
|
|
1200
|
+
// preserve the environment"). Docker socket access needs no sudo.
|
|
1201
|
+
if (exitCode !== 0) {
|
|
1202
|
+
const remaining = resolveOpctlDownTargets(resolved);
|
|
1203
|
+
if (remaining.length === 0) {
|
|
1204
|
+
onLine(
|
|
1205
|
+
`opctl exited ${exitCode} but the containers are stopped — down succeeded`,
|
|
1206
|
+
);
|
|
1207
|
+
onClose(0);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
onLine(
|
|
1211
|
+
`opctl container down failed (exit ${exitCode}); falling back to docker for: ${remaining.join(", ")}`,
|
|
1212
|
+
);
|
|
1213
|
+
const { stopped, failed } = forceStopOpctlContainers(remaining);
|
|
1214
|
+
for (const name of stopped) onLine(`docker rm -f ${name}`);
|
|
1215
|
+
if (failed.length > 0) {
|
|
1216
|
+
onLine(`docker fallback failed for: ${failed.join(", ")}`);
|
|
1217
|
+
} else if (resolveOpctlDownTargets(resolved).length === 0) {
|
|
1218
|
+
onLine(
|
|
1219
|
+
"docker fallback stopped remaining containers — down succeeded",
|
|
1220
|
+
);
|
|
1221
|
+
onClose(0);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
onClose(exitCode);
|
|
1226
|
+
},
|
|
1227
|
+
// The clone may not exist yet (fresh checkout, managed repo
|
|
1228
|
+
// never pulled) — a nonexistent cwd makes spawn fail with ENOENT and
|
|
1229
|
+
// the teardown "fails" while every container keeps running. opctl
|
|
1230
|
+
// doesn't need the repo dir to stop containers by name.
|
|
1231
|
+
fs.existsSync(repoPath) ? repoPath : undefined,
|
|
1232
|
+
false,
|
|
1233
|
+
);
|
|
1234
|
+
return { proc, detachable: false, interactive: false };
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const manifest = readManifest(repoPath);
|
|
1239
|
+
if (!manifest) {
|
|
1240
|
+
// Down stays conventional even with no manifest on disk (fresh checkout,
|
|
1241
|
+
// clone not yet created): no matching containers + nothing declared means
|
|
1242
|
+
// there is nothing to stop — success, same contract as the declared-down
|
|
1243
|
+
// fallthrough below.
|
|
1244
|
+
if (command === "down") {
|
|
1245
|
+
onLine(
|
|
1246
|
+
`nothing to stop (no matching opctl containers; no manifest at ${repoPath})`,
|
|
1247
|
+
);
|
|
1248
|
+
closeAsync(0);
|
|
1249
|
+
return dummy();
|
|
1250
|
+
}
|
|
1251
|
+
onLine(`Error: could not read ${product().manifestYml} in ${repoPath}`);
|
|
1252
|
+
closeAsync(1);
|
|
1253
|
+
return dummy();
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const svcCommands = effectiveLocalCommands(
|
|
1257
|
+
manifest,
|
|
1258
|
+
subsystem,
|
|
1259
|
+
resolved.repoName,
|
|
1260
|
+
workspace,
|
|
1261
|
+
);
|
|
1262
|
+
let cmdDef = svcCommands[command];
|
|
1263
|
+
const requestedCmdDef = cmdDef;
|
|
1264
|
+
|
|
1265
|
+
// `restart` fallback: synthesise `make down && make up` when no explicit
|
|
1266
|
+
// restart command exists but both down and up are defined.
|
|
1267
|
+
if (!cmdDef && command === "restart") {
|
|
1268
|
+
const downDef = svcCommands["down"];
|
|
1269
|
+
const upDef = svcCommands["up"];
|
|
1270
|
+
if (downDef && upDef) {
|
|
1271
|
+
const downCmd = rewriteLegacyOpctlKillCommand(
|
|
1272
|
+
"down",
|
|
1273
|
+
commandDefToString(downDef),
|
|
1274
|
+
resolved,
|
|
1275
|
+
);
|
|
1276
|
+
const upCmd = commandDefToString(upDef);
|
|
1277
|
+
cmdDef = `${downCmd} && ${upCmd}`;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (!cmdDef) {
|
|
1282
|
+
// Down is opctl-conventional: when no container is running AND the manifest
|
|
1283
|
+
// declares no down command, there is simply nothing to stop — that's a
|
|
1284
|
+
// successful down, not an error (keeps the ⋮ menu / workspace Down safe to
|
|
1285
|
+
// offer on every system).
|
|
1286
|
+
if (command === "down") {
|
|
1287
|
+
onLine("nothing to stop (no running opctl containers, no down command)");
|
|
1288
|
+
closeAsync(0);
|
|
1289
|
+
return dummy();
|
|
1290
|
+
}
|
|
1291
|
+
onLine(
|
|
1292
|
+
`Error: command "${command}" not found for service "${address}" (subsystem "${subsystem}") in local environment`,
|
|
1293
|
+
);
|
|
1294
|
+
closeAsync(1);
|
|
1295
|
+
return dummy();
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const cmdString = rewriteLegacyOpctlKillCommand(
|
|
1299
|
+
command,
|
|
1300
|
+
commandDefToString(cmdDef),
|
|
1301
|
+
resolved,
|
|
1302
|
+
onLine,
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
// Manifest downs that are just `opctl container down <name>` must not be
|
|
1306
|
+
// spawned raw: opctl re-elevates with `sudo -E` and fails on sudoers without
|
|
1307
|
+
// SETENV even when there is nothing to stop — which is why files-api/api
|
|
1308
|
+
// reported ✗ while connections/scribe's shell scripts reported ✓ (and left
|
|
1309
|
+
// containers running when discovery only trusted `opctl container ls`).
|
|
1310
|
+
// Nothing running → success. Something running → the resilient path above
|
|
1311
|
+
// already handles it via docker-aware discovery; if we somehow get here with
|
|
1312
|
+
// targets, route through the same docker fallback instead of a naked opctl.
|
|
1313
|
+
if (command === "down" && isOpctlContainerDownCommand(cmdString)) {
|
|
1314
|
+
// Bases the manifest names explicitly are the repo author's instruction and
|
|
1315
|
+
// are always honoured; the implicit repo-name addition only applies to
|
|
1316
|
+
// repo-owning addresses — an auxiliary subsystem must not widen its down
|
|
1317
|
+
// to the entrypoint's container.
|
|
1318
|
+
const bases = [
|
|
1319
|
+
...new Set([
|
|
1320
|
+
...opctlDownBasesFromCommand(cmdString),
|
|
1321
|
+
...(ownsRepoContainers(resolved) ? [resolved.repoName] : []),
|
|
1322
|
+
]),
|
|
1323
|
+
];
|
|
1324
|
+
const running = listRunningOpctlContainers()
|
|
1325
|
+
.map((c) => c.base)
|
|
1326
|
+
.filter((base) => bases.includes(base));
|
|
1327
|
+
if (running.length === 0) {
|
|
1328
|
+
onLine(
|
|
1329
|
+
`nothing to stop (no running opctl containers for ${bases.join(", ")})`,
|
|
1330
|
+
);
|
|
1331
|
+
unregisterDaemon(resolved.address);
|
|
1332
|
+
closeAsync(0);
|
|
1333
|
+
return dummy();
|
|
1334
|
+
}
|
|
1335
|
+
onLine(
|
|
1336
|
+
`opctl container down (manifest): ${running.join(", ")} — using docker-resilient path`,
|
|
1337
|
+
);
|
|
1338
|
+
const { stopped, failed } = forceStopOpctlContainers(running);
|
|
1339
|
+
for (const name of stopped) onLine(`docker rm -f ${name}`);
|
|
1340
|
+
if (failed.length > 0) {
|
|
1341
|
+
onLine(`docker stop failed for: ${failed.join(", ")}`);
|
|
1342
|
+
closeAsync(1);
|
|
1343
|
+
return dummy();
|
|
1344
|
+
}
|
|
1345
|
+
unregisterDaemon(resolved.address);
|
|
1346
|
+
closeAsync(0);
|
|
1347
|
+
return dummy();
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// down/setup always run to completion — never spawn as background daemons.
|
|
1351
|
+
// restart detaches unless its command is marked `detachable: false`.
|
|
1352
|
+
// All other string commands (typically "up") default to detachable.
|
|
1353
|
+
const detachable = shouldDetachServiceCommand(command, cmdDef);
|
|
1354
|
+
const interactive = isInteractiveServiceCommand(cmdDef);
|
|
1355
|
+
|
|
1356
|
+
if (interactive) {
|
|
1357
|
+
// HITL command — inherit the full TTY; onLine is not called.
|
|
1358
|
+
const proc = spawnInteractive(
|
|
1359
|
+
cmdString,
|
|
1360
|
+
{ ...process.env, ...env },
|
|
1361
|
+
onClose,
|
|
1362
|
+
repoPath,
|
|
1363
|
+
);
|
|
1364
|
+
return { proc, detachable: false, interactive: true };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const closeExistingTab = (): void => {
|
|
1368
|
+
const existing = lookupTab(resolved.address);
|
|
1369
|
+
if (!existing) return;
|
|
1370
|
+
try {
|
|
1371
|
+
closeTab(existing.tabId, existing.terminalApp);
|
|
1372
|
+
forgetTab(resolved.address);
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
onLine(`Warning: ${(error as Error).message}`);
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
const openCommandInTab = (commandToOpen: string): RunCommandResult => {
|
|
1379
|
+
try {
|
|
1380
|
+
const terminalApp = resolveTerminalApp(readUserConfig().terminal);
|
|
1381
|
+
const tab = openTab({
|
|
1382
|
+
cmd: commandToOpen,
|
|
1383
|
+
cwd: repoPath,
|
|
1384
|
+
label: resolved.address,
|
|
1385
|
+
terminalApp,
|
|
1386
|
+
env: tabEnv,
|
|
1387
|
+
});
|
|
1388
|
+
recordTab(resolved.address, {
|
|
1389
|
+
...tab,
|
|
1390
|
+
cwd: repoPath,
|
|
1391
|
+
command: commandToOpen,
|
|
1392
|
+
});
|
|
1393
|
+
onLine(
|
|
1394
|
+
`-> opened ${tab.terminalApp === "iterm2" ? "iTerm2" : "Terminal"} tab for ${resolved.address}`,
|
|
1395
|
+
);
|
|
1396
|
+
closeAsync(0);
|
|
1397
|
+
return {
|
|
1398
|
+
proc: new EventEmitter() as unknown as ChildProcess,
|
|
1399
|
+
detachable: false,
|
|
1400
|
+
interactive: false,
|
|
1401
|
+
tab,
|
|
1402
|
+
};
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
onLine(`Error: ${(error as Error).message}`);
|
|
1405
|
+
closeAsync(1);
|
|
1406
|
+
return dummy();
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
if (runtime === "tab" && command === "up") {
|
|
1411
|
+
return openCommandInTab(cmdString);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (runtime === "tab" && command === "restart") {
|
|
1415
|
+
closeExistingTab();
|
|
1416
|
+
if (requestedCmdDef) return openCommandInTab(cmdString);
|
|
1417
|
+
|
|
1418
|
+
const downDef = svcCommands["down"];
|
|
1419
|
+
const upDef = svcCommands["up"];
|
|
1420
|
+
if (downDef && upDef) {
|
|
1421
|
+
const downCmd = rewriteLegacyOpctlKillCommand(
|
|
1422
|
+
"down",
|
|
1423
|
+
commandDefToString(downDef),
|
|
1424
|
+
resolved,
|
|
1425
|
+
);
|
|
1426
|
+
const upCmd = commandDefToString(upDef);
|
|
1427
|
+
const proc = spawnStreaming(
|
|
1428
|
+
downCmd,
|
|
1429
|
+
[],
|
|
1430
|
+
{ ...process.env, ...env },
|
|
1431
|
+
onLine,
|
|
1432
|
+
(exitCode) => {
|
|
1433
|
+
if (exitCode !== 0) {
|
|
1434
|
+
onClose(exitCode);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const result = openCommandInTab(upCmd);
|
|
1438
|
+
void result;
|
|
1439
|
+
},
|
|
1440
|
+
repoPath,
|
|
1441
|
+
false,
|
|
1442
|
+
);
|
|
1443
|
+
return { proc, detachable: false, interactive: false };
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Detachable commands (up / restart) run as daemons: output is written to a
|
|
1448
|
+
// log file so the service survives den restarts. The returned proc is the
|
|
1449
|
+
// `tail` subprocess used for streaming — kill it to stop streaming.
|
|
1450
|
+
const logPath = detachable
|
|
1451
|
+
? getServiceLogPath(resolved.repoName, resolved.subsystem)
|
|
1452
|
+
: undefined;
|
|
1453
|
+
if (detachable && logPath) {
|
|
1454
|
+
onLine(`→ log: ${logPath}`);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Track long-running services so the shutdown interceptor can offer to stop them.
|
|
1458
|
+
// Snapshot running containers first so wrappedOnClose can diff what this run
|
|
1459
|
+
// actually spawned and refine the record.
|
|
1460
|
+
const preSpawnContainers =
|
|
1461
|
+
detachable && logPath && command !== "down" && isOpctlAvailable()
|
|
1462
|
+
? listRunningOpctlContainers()
|
|
1463
|
+
: null;
|
|
1464
|
+
if (detachable && logPath && command !== "down") {
|
|
1465
|
+
registerDaemon({
|
|
1466
|
+
address: resolved.address,
|
|
1467
|
+
repoName: resolved.repoName,
|
|
1468
|
+
subsystem: resolved.subsystem,
|
|
1469
|
+
repoPath: resolved.repoPath,
|
|
1470
|
+
branch: resolved.branch,
|
|
1471
|
+
workspaces: [workspace],
|
|
1472
|
+
logPath,
|
|
1473
|
+
startedAt: new Date(),
|
|
1474
|
+
// Seed with the service's declared opctl container names (affinity-
|
|
1475
|
+
// filtered for auxiliary subsystems) so `down` can stop them via
|
|
1476
|
+
// `opctl container down` without a declared down command. Refined with
|
|
1477
|
+
// the actually-spawned containers when the streaming tail detaches.
|
|
1478
|
+
// (Live `opctl container ls` is still the source of truth at down time;
|
|
1479
|
+
// this just seeds the candidate set.)
|
|
1480
|
+
containers: spawnContainerCandidates(resolved),
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// When a down command completes, remove from the registry. Scripted downs
|
|
1485
|
+
// (links-api's down.sh, etc.) can exit 0 while leaving opctl_<repo>_*
|
|
1486
|
+
// containers running — sweep those via docker so "✔ down" means stopped.
|
|
1487
|
+
const wrappedOnClose = (exitCode: number) => {
|
|
1488
|
+
if (command === "down") {
|
|
1489
|
+
const remaining = resolveOpctlDownTargets(resolved);
|
|
1490
|
+
let sweptClean = false;
|
|
1491
|
+
if (remaining.length > 0) {
|
|
1492
|
+
onLine(
|
|
1493
|
+
`down left running opctl containers — stopping via docker: ${remaining.join(", ")}`,
|
|
1494
|
+
);
|
|
1495
|
+
const { stopped, failed } = forceStopOpctlContainers(remaining);
|
|
1496
|
+
for (const name of stopped) onLine(`docker rm -f ${name}`);
|
|
1497
|
+
if (failed.length > 0) {
|
|
1498
|
+
onLine(`docker stop failed for: ${failed.join(", ")}`);
|
|
1499
|
+
onClose(1);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
sweptClean = true;
|
|
1503
|
+
}
|
|
1504
|
+
// A non-zero down that we then verifiably swept clean IS a successful
|
|
1505
|
+
// teardown. A non-zero down with nothing to sweep is not: for a service
|
|
1506
|
+
// whose containers den can't see (non-opctl, or opctl unavailable),
|
|
1507
|
+
// "nothing left to stop" is an absence of evidence, not evidence the
|
|
1508
|
+
// teardown worked — reporting ✔ there would hide every real failure.
|
|
1509
|
+
const effective = exitCode === 0 || sweptClean ? 0 : exitCode;
|
|
1510
|
+
if (effective === 0) {
|
|
1511
|
+
unregisterDaemon(resolved.address);
|
|
1512
|
+
closeExistingTab();
|
|
1513
|
+
}
|
|
1514
|
+
onClose(effective);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
// Ups fire onClose when the streaming tail detaches — by then the run's
|
|
1518
|
+
// containers usually exist, so record what actually appeared. Best-effort:
|
|
1519
|
+
// slow image builds may not have spawned yet; the declared seed remains.
|
|
1520
|
+
if (preSpawnContainers) {
|
|
1521
|
+
updateDaemonContainers(
|
|
1522
|
+
resolved.address,
|
|
1523
|
+
diffSpawnedContainers(preSpawnContainers, listRunningOpctlContainers()),
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
onClose(exitCode);
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const proc = spawnStreaming(
|
|
1530
|
+
cmdString,
|
|
1531
|
+
[],
|
|
1532
|
+
{ ...process.env, ...env },
|
|
1533
|
+
onLine,
|
|
1534
|
+
wrappedOnClose,
|
|
1535
|
+
repoPath,
|
|
1536
|
+
detachable,
|
|
1537
|
+
logPath,
|
|
1538
|
+
onServiceExit,
|
|
1539
|
+
);
|
|
1540
|
+
return { proc, detachable, interactive: false, logPath };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// ── Health checks ─────────────────────────────────────────────────────────────
|
|
1544
|
+
// (implementation lives in health.ts — re-exported above)
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Check health for all services in the active workspace, in parallel.
|
|
1548
|
+
* Uses catalog-wide scan so mono-repo subsystems are shown individually.
|
|
1549
|
+
*/
|
|
1550
|
+
export async function checkWorkspaceHealth(): Promise<ServiceHealthResult[]> {
|
|
1551
|
+
return checkCatalogHealth();
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ── Catalog + status listing ──────────────────────────────────────────────────
|
|
1555
|
+
|
|
1556
|
+
export interface ServiceListEntry extends ServiceHealthResult {
|
|
1557
|
+
commandsAvailable: string[];
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* List all services in the active workspace with health and available commands.
|
|
1562
|
+
*/
|
|
1563
|
+
export async function listServices(
|
|
1564
|
+
workspaceName?: string,
|
|
1565
|
+
): Promise<ServiceListEntry[]> {
|
|
1566
|
+
const ws = workspaceName
|
|
1567
|
+
? getWorkspaceByName(workspaceName)
|
|
1568
|
+
: getActiveWorkspace();
|
|
1569
|
+
if (!ws) return [];
|
|
1570
|
+
const catalog = readCatalog();
|
|
1571
|
+
// Modules and libraries have no runtime: no card, no health, no commands.
|
|
1572
|
+
const services = listWorkspaceServices(ws.config, catalog, ws.name).filter(
|
|
1573
|
+
(s) => hasRuntime(repoKind(s.repoPath)),
|
|
1574
|
+
);
|
|
1575
|
+
const healthResults = await Promise.all(
|
|
1576
|
+
services.map((s) => checkServiceHealth(s)),
|
|
1577
|
+
);
|
|
1578
|
+
|
|
1579
|
+
return healthResults.map((h, i) => {
|
|
1580
|
+
const resolved = services[i];
|
|
1581
|
+
let commandsAvailable: string[] = [];
|
|
1582
|
+
try {
|
|
1583
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1584
|
+
commandsAvailable = manifest
|
|
1585
|
+
? Object.keys(
|
|
1586
|
+
effectiveLocalCommands(
|
|
1587
|
+
manifest,
|
|
1588
|
+
resolved.subsystem,
|
|
1589
|
+
resolved.repoName,
|
|
1590
|
+
ws.name,
|
|
1591
|
+
),
|
|
1592
|
+
)
|
|
1593
|
+
: [];
|
|
1594
|
+
} catch {
|
|
1595
|
+
/* ignore */
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
return {
|
|
1599
|
+
...h,
|
|
1600
|
+
commandsAvailable,
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
}
|