@monster-orange/den 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +1 -1
  3. package/bin/den +8 -0
  4. package/docs/distributions.md +283 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +19 -2
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +333 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +184 -55
  35. package/src/ui/cli/mcp-connect.ts +350 -0
  36. package/src/ui/cli/mcp-main.ts +13 -0
  37. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  38. package/src/ui/cli/named-target.ts +11 -0
  39. package/src/ui/cli/stack-command.ts +13 -40
  40. package/src/ui/cli/test-runner.ts +8 -1
  41. package/src/ui/mcp/api.ts +286 -0
  42. package/src/ui/mcp/connect.ts +304 -0
  43. package/src/ui/mcp/guards.ts +145 -0
  44. package/src/ui/mcp/server.ts +149 -0
  45. package/src/ui/mcp/tools.ts +644 -0
  46. package/src/ui/server/audit.ts +137 -0
  47. package/src/ui/server/changelog-parser.ts +11 -0
  48. package/src/ui/server/graph-prefs.ts +89 -0
  49. package/src/ui/server/health-row.ts +15 -0
  50. package/src/ui/server/index.ts +256 -61
  51. package/src/ui/server/lifecycle-exec.ts +188 -0
  52. package/src/ui/server/log-history.ts +4 -3
  53. package/src/ui/server/public/environments.html +4 -2
  54. package/src/ui/server/public/graph-model.js +416 -0
  55. package/src/ui/server/public/graph.css +980 -0
  56. package/src/ui/server/public/graph.js +986 -0
  57. package/src/ui/server/public/header.js +7 -5
  58. package/src/ui/server/public/local-dev.html +130 -2191
  59. package/src/ui/server/public/logs.html +11 -4
  60. package/src/ui/server/public/panel.js +790 -0
  61. package/src/ui/server/public/services.html +6 -1
  62. package/src/ui/server/readonly-routes.ts +209 -0
  63. package/src/ui/server/ui-assets.ts +73 -0
  64. package/src/ui/server/workspace-routes.ts +39 -8
@@ -35,7 +35,11 @@ import type {
35
35
  TestResult,
36
36
  } from "../../shared/types/index.js";
37
37
  import { getDistRoot } from "../../shared/paths.js";
38
- import { readSecurityAutoTargets } from "../../shared/den-config.js";
38
+ import {
39
+ isProductionEnv,
40
+ readSecurityAutoTargets,
41
+ } from "../../shared/den-config.js";
42
+ import { isProtectedRemote } from "../../shared/manifest/placement.js";
39
43
  import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
40
44
  import {
41
45
  autoSecurityTargets,
@@ -67,6 +71,7 @@ import {
67
71
  } from "./plan.js";
68
72
 
69
73
  import { envKey, product } from "../../shared/product.js";
74
+ import { containsValue, redactValues } from "../../shared/redact.js";
70
75
  // ── Exit codes ───────────────────────────────────────────────────────────────
71
76
 
72
77
  export const EXIT_CLEAN = 0;
@@ -189,6 +194,8 @@ export interface EntrypointDeps {
189
194
  now: () => Date;
190
195
  log: (message: string) => void;
191
196
  logError: (message: string) => void;
197
+ /** Whether the org counts an env as production (default: org config). */
198
+ isProduction?: (env: string) => boolean;
192
199
  }
193
200
 
194
201
  /** Production wiring: real docker, real token minting, real result writer. */
@@ -291,8 +298,8 @@ function redactFile(
291
298
  ): void {
292
299
  if (secret === undefined || secret.length === 0) return;
293
300
  const contents = deps.readFileIfExists(filePath);
294
- if (contents !== undefined && contents.includes(secret)) {
295
- deps.writeFile(filePath, contents.split(secret).join("[REDACTED]"));
301
+ if (contents !== undefined && containsValue(contents, [secret])) {
302
+ deps.writeFile(filePath, redactValues(contents, [secret]));
296
303
  }
297
304
  }
298
305
 
@@ -574,7 +581,28 @@ export async function runSecurityCli(
574
581
  const units: ScanUnit[] = [];
575
582
  try {
576
583
  const suffixes = deps.ownedHosts();
584
+ const isProduction =
585
+ deps.isProduction ?? ((e: string) => isProductionEnv(e));
577
586
  for (const target of targets) {
587
+ // R13: a remote service whose target env counts as production is never
588
+ // scanned; any other remote one is scanned only in its own env, never
589
+ // as if it ran locally.
590
+ const placed = target.placement;
591
+ if (placed?.runs === "remote" && placed.target) {
592
+ const where = placed.target.env;
593
+ if (isProtectedRemote(placed, isProduction)) {
594
+ deps.log(
595
+ `↷ skipping ${securityTargetId(target)}: runs in ${where}, which counts as production`,
596
+ );
597
+ continue;
598
+ }
599
+ if (args.env === "local") {
600
+ deps.log(
601
+ `↷ skipping ${securityTargetId(target)}: runs in ${where}, not on this machine`,
602
+ );
603
+ continue;
604
+ }
605
+ }
578
606
  // A target that declares no host for THIS env (e.g. only staging/prod)
579
607
  // is simply not in scope here — skip it, never abort the batch.
580
608
  const inEnv =
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Who asked: the headers the MCP server (`<cli> mcp`) sends with every web
3
+ * API call, so the server can record an action as an agent's. The client
4
+ * name and version are what the MCP client reported about itself at
5
+ * initialize: a label for the audit trail, never used for authorization.
6
+ */
7
+ import type { IncomingHttpHeaders } from "http";
8
+
9
+ export const AUDIT_HEADERS = {
10
+ actor: "x-den-actor",
11
+ client: "x-den-mcp-client",
12
+ clientVersion: "x-den-mcp-client-version",
13
+ pid: "x-den-mcp-pid",
14
+ session: "x-den-mcp-session",
15
+ } as const;
16
+
17
+ export interface AgentInfo {
18
+ /** The MCP client's self-reported name (clientInfo.name). */
19
+ client: string;
20
+ /** The MCP client's self-reported version (clientInfo.version). */
21
+ clientVersion: string;
22
+ /** The MCP server process's pid. */
23
+ mcpPid: number | null;
24
+ /** The MCP server's session id (one per `<cli> mcp` process). */
25
+ session: string;
26
+ }
27
+
28
+ export interface AgentActor extends AgentInfo {
29
+ kind: "agent";
30
+ }
31
+
32
+ /** null: a person in the web UI (no agent headers). */
33
+ export type Actor = AgentActor | null;
34
+
35
+ /** Printable, single-line, bounded: these values land in log lines. */
36
+ function clean(v: unknown, max = 64): string {
37
+ const s = Array.isArray(v) ? v[0] : v;
38
+ return String(s ?? "")
39
+ .replace(/[^\x20-\x7e]/g, "")
40
+ .trim()
41
+ .slice(0, max);
42
+ }
43
+
44
+ /** The headers the MCP client sends (see AUDIT_HEADERS). */
45
+ export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
46
+ return {
47
+ [AUDIT_HEADERS.actor]: "agent",
48
+ [AUDIT_HEADERS.client]: clean(info.client) || "unknown",
49
+ [AUDIT_HEADERS.clientVersion]: clean(info.clientVersion) || "unknown",
50
+ [AUDIT_HEADERS.pid]: info.mcpPid === null ? "" : String(info.mcpPid),
51
+ [AUDIT_HEADERS.session]: clean(info.session).replace(/[^A-Za-z0-9-]/g, ""),
52
+ };
53
+ }
54
+
55
+ /** The actor an incoming request names, or null for a person in the UI. */
56
+ export function actorFromHeaders(
57
+ headers: IncomingHttpHeaders | Record<string, string | undefined>,
58
+ ): Actor {
59
+ const h = headers as Record<string, string | string[] | undefined>;
60
+ if (clean(h[AUDIT_HEADERS.actor]) !== "agent") return null;
61
+ const pid = clean(h[AUDIT_HEADERS.pid], 10);
62
+ return {
63
+ kind: "agent",
64
+ client: clean(h[AUDIT_HEADERS.client]) || "unknown",
65
+ clientVersion: clean(h[AUDIT_HEADERS.clientVersion], 32) || "unknown",
66
+ mcpPid: /^\d+$/.test(pid) ? Number(pid) : null,
67
+ session: clean(h[AUDIT_HEADERS.session]).replace(/[^A-Za-z0-9-]/g, ""),
68
+ };
69
+ }
70
+
71
+ /** "agent <client> <version> (mcp pid <pid>, session <id>)". */
72
+ export function actorLabel(actor: AgentActor): string {
73
+ return (
74
+ `agent ${actor.client} ${actor.clientVersion} ` +
75
+ `(mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
76
+ );
77
+ }
@@ -178,6 +178,12 @@ export interface DenConfig {
178
178
  secrets?: SecretsConfig;
179
179
  /** Environments enabled for this deployment. */
180
180
  environments: Environment[];
181
+ /**
182
+ * The environments that count as production (default: prod, production).
183
+ * Production is an attribute of an env, never a name check: Den refuses to
184
+ * start, stop, scan or exec a remote service whose target env is listed.
185
+ */
186
+ production_envs?: string[];
181
187
  /**
182
188
  * Root directory for local repo discovery, relative to the den directory.
183
189
  * Defaults to ".." (the parent of den), which covers the common layout where
@@ -227,6 +233,11 @@ export interface DenConfig {
227
233
  env_health_urls?: Record<string, string>;
228
234
  /** Security pillar (OWASP ZAP) policy — see readSecurityOwnedHosts. */
229
235
  security?: SecurityConfig;
236
+ /**
237
+ * The MCP server (`<cli> mcp`). Without this block agents get read tools
238
+ * only; `actions` opts in to named actions. See readMcpConfig.
239
+ */
240
+ mcp?: McpConfig;
230
241
  /**
231
242
  * Shared test accounts (email + secrets-backend item per role) the test
232
243
  * pillars log in as — see src/shared/auth/test-accounts.ts.
@@ -299,6 +310,16 @@ export interface UserConfig {
299
310
  * local and never get committed. Merged field-wise over the team base.
300
311
  */
301
312
  release?: Partial<ReleaseConfig>;
313
+ /**
314
+ * Graph layout preferences (direction, grouping, card density, panel
315
+ * width), set from the Local Dev graph. See src/ui/server/graph-prefs.ts.
316
+ */
317
+ graph?: {
318
+ direction?: string;
319
+ group?: string;
320
+ density?: string;
321
+ panelWidth?: number;
322
+ };
302
323
  }
303
324
 
304
325
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -311,6 +332,11 @@ const ALL_ENVIRONMENTS: Environment[] = [
311
332
  "production",
312
333
  ];
313
334
 
335
+ /** Den's built-in environment names (used when the org config lists none). */
336
+ export function builtinEnvironments(): Environment[] {
337
+ return [...ALL_ENVIRONMENTS];
338
+ }
339
+
314
340
  const DEFAULT_CONFIG: DenConfig = {
315
341
  environments: ALL_ENVIRONMENTS,
316
342
  };
@@ -388,6 +414,7 @@ export function readDenConfig(): DenConfig {
388
414
  environments: envs.filter((e): e is Environment =>
389
415
  ALL_ENVIRONMENTS.includes(e as Environment),
390
416
  ),
417
+ production_envs: parsed?.production_envs,
391
418
  repos_root: parsed?.repos_root,
392
419
  github_org: parsed?.github_org,
393
420
  orgs: parsed?.orgs,
@@ -398,6 +425,7 @@ export function readDenConfig(): DenConfig {
398
425
  service_env: parsed?.service_env,
399
426
  env_health_urls: parsed?.env_health_urls,
400
427
  security: parsed?.security,
428
+ mcp: parsed?.mcp,
401
429
  test_accounts: parsed?.test_accounts,
402
430
  defaults: parsed?.defaults,
403
431
  secrets: parsed?.secrets,
@@ -428,6 +456,39 @@ export function getReposRoot(): string {
428
456
  return path.resolve(ROOT, config.repos_root ?? "..");
429
457
  }
430
458
 
459
+ const DEFAULT_PRODUCTION_ENVS = ["prod", "production"];
460
+
461
+ /** The envs the org counts as production (`production_envs`). */
462
+ export function productionEnvs(config: DenConfig = readDenConfig()): string[] {
463
+ const listed = config.production_envs;
464
+ return Array.isArray(listed) &&
465
+ listed.every((e): e is string => typeof e === "string")
466
+ ? listed
467
+ : DEFAULT_PRODUCTION_ENVS;
468
+ }
469
+
470
+ /**
471
+ * Whether the org counts `env` as production. An unreadable org config falls
472
+ * back to the default list rather than letting a guard throw.
473
+ */
474
+ export function isProductionEnv(env: string, config?: DenConfig): boolean {
475
+ let listed: string[];
476
+ try {
477
+ listed = productionEnvs(config ?? readDenConfig());
478
+ } catch {
479
+ listed = DEFAULT_PRODUCTION_ENVS;
480
+ }
481
+ return listed.includes(env);
482
+ }
483
+
484
+ /**
485
+ * The org's environment names, for validating manifests: the config's
486
+ * `environments` (Den's built-in list when unset), always with `local`.
487
+ */
488
+ export function orgEnvironments(config: DenConfig = readDenConfig()): string[] {
489
+ return [...new Set<string>(["local", ...config.environments])];
490
+ }
491
+
431
492
  /** True when the `local` environment is enabled for this engineer. */
432
493
  export function isLocalEnabled(): boolean {
433
494
  return readDenConfig().environments.includes("local");
@@ -480,6 +541,67 @@ export function readSecurityAutoTargets(
480
541
  return config.security?.auto_targets === true;
481
542
  }
482
543
 
544
+ // ── MCP config (`<cli> mcp`) ───────────────────────────────────────────────────
545
+
546
+ /** The action tools an org can opt in to. Read tools are always on. */
547
+ export const MCP_ACTIONS = [
548
+ "service_up",
549
+ "service_down",
550
+ "service_restart",
551
+ "workspace_sync",
552
+ "run_tests",
553
+ ] as const;
554
+ export type McpAction = (typeof MCP_ACTIONS)[number];
555
+
556
+ /** `mcp:` in the org config. */
557
+ export interface McpConfig {
558
+ /** Action tools to register; none when absent. */
559
+ actions?: string[];
560
+ /** Environments `run_tests` may target (default [local]; production never). */
561
+ run_tests?: { envs?: string[] };
562
+ /** `remote: true` lets the log tools read non-local sources (e.g. CloudWatch). */
563
+ logs?: { remote?: boolean };
564
+ }
565
+
566
+ /** `mcp:` with its defaults applied. */
567
+ export interface ResolvedMcpConfig {
568
+ actions: McpAction[];
569
+ runTestsEnvs: Environment[];
570
+ logsRemote: boolean;
571
+ }
572
+
573
+ /**
574
+ * The effective MCP config. An unknown action or environment name throws:
575
+ * a typo must not silently turn a guard off (or an action on).
576
+ */
577
+ export function readMcpConfig(
578
+ config: DenConfig = readDenConfig(),
579
+ ): ResolvedMcpConfig {
580
+ const raw = config.mcp ?? {};
581
+ const actions = (Array.isArray(raw.actions) ? raw.actions : []).map((a) => {
582
+ if (!(MCP_ACTIONS as readonly string[]).includes(String(a)))
583
+ throw new Error(
584
+ `mcp.actions: unknown action "${String(a)}" (known: ${MCP_ACTIONS.join(", ")})`,
585
+ );
586
+ return a as McpAction;
587
+ });
588
+ const envList = raw.run_tests?.envs;
589
+ const runTestsEnvs = (Array.isArray(envList) ? envList : ["local"]).map(
590
+ (e) => {
591
+ if (!ALL_ENVIRONMENTS.includes(e as Environment))
592
+ throw new Error(
593
+ `mcp.run_tests.envs: unknown environment "${String(e)}" (known: ${ALL_ENVIRONMENTS.join(", ")})`,
594
+ );
595
+ return e as Environment;
596
+ },
597
+ );
598
+ return {
599
+ actions: [...new Set(actions)],
600
+ runTestsEnvs: [...new Set(runTestsEnvs)],
601
+ logsRemote: raw.logs?.remote === true,
602
+ };
603
+ }
604
+
483
605
  // ── Release config (Release tab) ──────────────────────────────────────────────
484
606
 
485
607
  /**
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Read-only edge status: what `edge status` shows, as data. The CLI prints
3
+ * it; the web server serves it at /api/edge (and through it the MCP
4
+ * `edge_status` tool).
5
+ */
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { certNames, edgeDomains, edgeNames, routeUrl } from "./render.js";
9
+ import { caTrusted, containerState } from "./runtime.js";
10
+ import type { EdgeConfig, EdgePorts } from "./types.js";
11
+
12
+ export interface EdgeStatus {
13
+ org: string;
14
+ container: string;
15
+ /** Docker's container state ("running", "exited", …) or "absent". */
16
+ state: string;
17
+ up: boolean;
18
+ /** Published host ports, or null when another proxy fronts the edge. */
19
+ ports: EdgePorts | null;
20
+ network: string;
21
+ domains: string[];
22
+ routes: { name: string; url: string; upstream: string }[];
23
+ /** Names on the issued cert, or the planned ones when none is issued yet. */
24
+ certNames: string[];
25
+ certIssued: boolean;
26
+ /** Whether the CA is trusted on this machine (null: can't tell). */
27
+ caTrusted: boolean | null;
28
+ }
29
+
30
+ export interface EdgeStatusDeps {
31
+ containerState: (name: string) => string;
32
+ caTrusted: (cfg: EdgeConfig) => boolean | null;
33
+ /** Names on the issued cert (its stamp file), or null when none is issued. */
34
+ issuedNames: (cfg: EdgeConfig) => string[] | null;
35
+ }
36
+
37
+ /** The names stamp `ensureCerts` writes beside the cert. */
38
+ export function issuedCertNames(cfg: EdgeConfig): string[] | null {
39
+ const stamp = path.join(cfg.stateDir, "certs", "names.txt");
40
+ if (!fs.existsSync(stamp)) return null;
41
+ return fs.readFileSync(stamp, "utf-8").split("\n").filter(Boolean);
42
+ }
43
+
44
+ const defaultDeps: EdgeStatusDeps = {
45
+ containerState,
46
+ caTrusted,
47
+ issuedNames: issuedCertNames,
48
+ };
49
+
50
+ export function edgeStatus(
51
+ cfg: EdgeConfig,
52
+ deps: EdgeStatusDeps = defaultDeps,
53
+ ): EdgeStatus {
54
+ const n = edgeNames(cfg.org);
55
+ const state = deps.containerState(n.container);
56
+ const issued = deps.issuedNames(cfg);
57
+ return {
58
+ org: cfg.org,
59
+ container: n.container,
60
+ state,
61
+ up: state === "running",
62
+ ports: cfg.ports,
63
+ network: n.network,
64
+ domains: edgeDomains(cfg),
65
+ routes: Object.entries(cfg.routes).map(([name, upstream]) => ({
66
+ name,
67
+ url: routeUrl(name, cfg),
68
+ upstream,
69
+ })),
70
+ certNames: issued ?? certNames(cfg),
71
+ certIssued: issued !== null,
72
+ caTrusted: deps.caTrusted(cfg),
73
+ };
74
+ }
@@ -14,8 +14,18 @@ import * as fs from "fs";
14
14
  import * as path from "path";
15
15
  import * as dns from "dns";
16
16
  import { spawn } from "child_process";
17
- import { HEALTH_CHECK_TIMEOUT_MS, CLI_SPAWN_TIMEOUT_MS } from "../constants.js";
17
+ import {
18
+ HEALTH_CHECK_TIMEOUT_MS,
19
+ HOSTED_HEALTH_CHECK_TIMEOUT_MS,
20
+ CLI_SPAWN_TIMEOUT_MS,
21
+ } from "../constants.js";
18
22
  import { readManifest } from "../manifest/read-manifest.js";
23
+ import {
24
+ expectMatches,
25
+ resolveHealthCheck,
26
+ type Expect,
27
+ type Placement,
28
+ } from "../manifest/placement.js";
19
29
  import { readCatalog } from "./catalog.js";
20
30
  import {
21
31
  getActiveWorkspace,
@@ -42,6 +52,9 @@ export interface ServiceHealthResult {
42
52
  branch: string | null;
43
53
  health: "ok" | "error" | "unknown";
44
54
  healthDetail?: string;
55
+ /** Where the service runs for the env checked (R13). */
56
+ runs?: Placement["runs"];
57
+ target?: Placement["target"];
45
58
  }
46
59
 
47
60
  // ── Name resolution ───────────────────────────────────────────────────────────
@@ -288,7 +301,16 @@ const LOCALHOST_FALLBACK_TIMEOUT_MS = 1_500;
288
301
  export async function pingHttp(
289
302
  url: string,
290
303
  timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
304
+ expect?: Expect,
291
305
  ): Promise<PingResult> {
306
+ // A remote target's probe (target.url): up only on the expected codes, and
307
+ // never a localhost fallback (it is not on this machine).
308
+ if (expect !== undefined) {
309
+ const r = await pingOnce(url, timeoutMs);
310
+ return r.status === undefined
311
+ ? r
312
+ : { ok: expectMatches(r.status, expect), status: r.status };
313
+ }
292
314
  const result = await pingOnce(url, timeoutMs);
293
315
  if (!result.ok) {
294
316
  // Fall back to localhost for Docker-style hostnames regardless of how the
@@ -432,12 +454,25 @@ export async function checkServiceHealth(
432
454
  getFocusedWorkspaceName() ?? "default",
433
455
  )
434
456
  : null;
435
- const healthCfg =
436
- manifest.services?.[resolved.subsystem]?.health?.[env] ??
437
- (composeHealth
438
- ? { type: "shell" as const, cmd: composeHealth }
439
- : undefined);
457
+ const plan = resolveHealthCheck(
458
+ manifest,
459
+ resolved.subsystem,
460
+ env,
461
+ composeHealth,
462
+ );
463
+ const placed: Pick<ServiceHealthResult, "runs" | "target"> = {
464
+ runs: plan.placement.runs,
465
+ target: plan.placement.target,
466
+ };
467
+ const healthCfg = plan.check;
440
468
  if (!healthCfg) {
469
+ if (plan.placement.runs === "remote")
470
+ return {
471
+ ...base,
472
+ ...placed,
473
+ health: "unknown",
474
+ healthDetail: "no health check configured",
475
+ };
441
476
  // Conventional local discovery: opctl exposes each system on the host at
442
477
  // http://<repo> (port 80), and stack services serve /healthz. When the
443
478
  // PRIMARY service (entrypoint, or a repo's only service) declares no local
@@ -460,6 +495,7 @@ export async function checkServiceHealth(
460
495
  if (probe.ok && probe.status !== undefined && probe.status < 300) {
461
496
  return {
462
497
  ...base,
498
+ ...placed,
463
499
  health: "ok",
464
500
  healthDetail: `conventional ${conventional}`,
465
501
  };
@@ -467,6 +503,7 @@ export async function checkServiceHealth(
467
503
  }
468
504
  return {
469
505
  ...base,
506
+ ...placed,
470
507
  health: "unknown",
471
508
  healthDetail: "no health check configured",
472
509
  };
@@ -475,10 +512,17 @@ export async function checkServiceHealth(
475
512
  const result =
476
513
  healthCfg.type === "shell"
477
514
  ? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
478
- : await pingHttp(healthCfg.url);
515
+ : await pingHttp(
516
+ healthCfg.url,
517
+ env === "local" && plan.placement.runs !== "remote"
518
+ ? HEALTH_CHECK_TIMEOUT_MS
519
+ : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
520
+ plan.expect,
521
+ );
479
522
 
480
523
  return {
481
524
  ...base,
525
+ ...placed,
482
526
  health: result.ok ? "ok" : "error",
483
527
  healthDetail:
484
528
  result.error ??
@@ -554,8 +598,10 @@ export function rollupRepoHealth(
554
598
  ? rows.find((r) => r.subsystem === entrypoint)
555
599
  : undefined;
556
600
  if (epRow) {
557
- // Show the entrypoint's health under the clean repo-name address.
601
+ // Show the entrypoint's health under the clean repo-name address. A
602
+ // remote service is not a sidecar: it keeps its own row (R13).
558
603
  out.push({ ...epRow, address: repoName });
604
+ out.push(...rows.filter((r) => r !== epRow && r.runs === "remote"));
559
605
  } else {
560
606
  // No entrypoint → infra provider; keep the subsystem breakdown.
561
607
  out.push(...rows);
@@ -8,6 +8,12 @@ import { deriveGraph } from "./derivation/derive-graph.js";
8
8
  import type { DerivedGraph } from "./derivation/types.js";
9
9
  import { getWorkspaceServices } from "./service-runner.js";
10
10
  import { readOrg, type Org } from "../org.js";
11
+ import {
12
+ rollupPlacement,
13
+ servicePlacement,
14
+ type Placement,
15
+ type RollupPlacement,
16
+ } from "../manifest/placement.js";
11
17
 
12
18
  export function loadApplications(workspace?: string): Application[] {
13
19
  const services = getWorkspaceServices(workspace);
@@ -47,6 +53,12 @@ export interface ApplicationView extends Application {
47
53
  addresses: string[];
48
54
  /** Repos the team config lists for it that are not in this workspace. */
49
55
  absent: string[];
56
+ /** address → where that service runs locally, and where a remote one is. */
57
+ placements: Record<string, Placement>;
58
+ /** local | remote | hybrid | none, over its services (R13). */
59
+ placement: RollupPlacement;
60
+ /** "shop-frontend local · shop-core remote (dev)" */
61
+ summary: string;
50
62
  }
51
63
 
52
64
  /** Org > Application > Repo > Services for a workspace (CLI `apps`, web UI). */
@@ -55,9 +67,18 @@ export function loadApplicationTree(workspace?: string): {
55
67
  applications: ApplicationView[];
56
68
  } {
57
69
  const byRepo = new Map<string, string[]>();
70
+ const placed = new Map<
71
+ string,
72
+ Placement & { repo: string; service: string }
73
+ >();
58
74
  for (const s of getWorkspaceServices(workspace)) {
59
75
  if (!byRepo.has(s.repoName)) byRepo.set(s.repoName, []);
60
76
  byRepo.get(s.repoName)!.push(s.address);
77
+ placed.set(s.address, {
78
+ repo: s.repoName,
79
+ service: s.subsystem,
80
+ ...servicePlacement(readManifest(s.repoPath), s.subsystem, "local"),
81
+ });
61
82
  }
62
83
  const config = readDenConfig().applications ?? {};
63
84
  const org = readOrg();
@@ -67,11 +88,20 @@ export function loadApplicationTree(workspace?: string): {
67
88
  const services = Object.fromEntries(
68
89
  a.repos.map((r) => [r, byRepo.get(r) ?? []]),
69
90
  );
91
+ const addresses = Object.values(services).flat();
92
+ const rows = addresses.flatMap((addr) => placed.get(addr) ?? []);
70
93
  return {
71
94
  ...a,
72
95
  services,
73
- addresses: Object.values(services).flat(),
96
+ addresses,
74
97
  absent: (config[a.name]?.repos ?? []).filter((r) => !byRepo.has(r)),
98
+ placements: Object.fromEntries(
99
+ addresses.flatMap((addr) => {
100
+ const p = placed.get(addr);
101
+ return p ? [[addr, { runs: p.runs, target: p.target }]] : [];
102
+ }),
103
+ ),
104
+ ...rollupPlacement(rows),
75
105
  };
76
106
  }),
77
107
  };
@@ -2,8 +2,9 @@
2
2
  * Live log tailing for a local service — the CLI-side counterpart of the web
3
3
  * /api/service/taillog SSE endpoint. Source resolution, in order:
4
4
  * 1. The service's manifest `logs.local` shell command (e.g. `docker logs -f`)
5
- * 2. Conventional container derivation: a running `opctl_<base>_<hash>`
6
- * container → `docker logs -f` — real runtime logs with zero manifest config
5
+ * 2. The service's running container → `docker logs -f`: a compose stack's
6
+ * `<stack>-<workspace>-<service>-1`, else an `opctl_<base>_<hash>` one —
7
+ * real runtime logs with zero manifest config
7
8
  * 3. The den daemon startup log (~/.den/logs/<repo>/<subsystem>.log) —
8
9
  * last resort, covers host-run processes with no container
9
10
  * A source whose command exits within the quick-exit window (container not
@@ -13,7 +14,7 @@
13
14
  import * as fs from "fs";
14
15
  import { DAEMON_QUICK_EXIT_MS, LIVE_TAIL_LINES } from "../constants.js";
15
16
  import { readManifest } from "../manifest/read-manifest.js";
16
- import { findConventionalContainer } from "./opctl.js";
17
+ import { findServiceContainer } from "./service-container.js";
17
18
  import {
18
19
  getServiceLogPath,
19
20
  spawnStreaming,
@@ -85,7 +86,7 @@ export function tailSourceCandidates(
85
86
  export function resolveTailSources(resolved: ResolvedService): TailSource[] {
86
87
  const manifest = readManifest(resolved.repoPath);
87
88
  const logSource = manifest?.services?.[resolved.subsystem]?.logs?.local;
88
- const container = findConventionalContainer(
89
+ const container = findServiceContainer(
89
90
  resolved.repoName,
90
91
  resolved.repoPath,
91
92
  resolved.subsystem,
@@ -100,7 +100,8 @@ export function isOpctlContainerFor(name: string, base: string): boolean {
100
100
  return suffix.length > 0 && !suffix.includes("_");
101
101
  }
102
102
 
103
- function runningDockerNames(): string[] {
103
+ /** Names of the running docker containers (throws when docker is unavailable). */
104
+ export function runningDockerNames(): string[] {
104
105
  return execSync('docker ps --format "{{.Names}}"', {
105
106
  encoding: "utf-8",
106
107
  stdio: ["ignore", "pipe", "ignore"],
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The running container that holds a service's logs. A compose-stack service
3
+ * runs as `<stack>-<workspace>-<service>-1` (the stacks naming helpers); any
4
+ * other container-run service is found by opctl's naming convention.
5
+ */
6
+ import { readManifest } from "../manifest/read-manifest.js";
7
+ import { composeServiceContainers } from "../stacks/manifest-commands.js";
8
+ import { resolveStackWorkspaces } from "../stacks/status.js";
9
+ import { findConventionalContainer, runningDockerNames } from "./opctl.js";
10
+
11
+ /** The stack's container for `subsystem` when one is running, else null. */
12
+ export function findStackServiceContainer(
13
+ repoName: string,
14
+ repoPath: string | undefined,
15
+ subsystem: string | undefined,
16
+ ): string | null {
17
+ if (!repoPath || !subsystem) return null;
18
+ const manifest = readManifest(repoPath);
19
+ if (!manifest?.compose) return null;
20
+ let candidates: string[];
21
+ let running: string[];
22
+ try {
23
+ const { stackWorkspace } = resolveStackWorkspaces();
24
+ candidates = composeServiceContainers(
25
+ manifest,
26
+ subsystem,
27
+ repoName,
28
+ stackWorkspace,
29
+ );
30
+ running = runningDockerNames();
31
+ } catch {
32
+ return null; // bad workspace name, or docker unavailable
33
+ }
34
+ return candidates.find((c) => running.includes(c)) ?? null;
35
+ }
36
+
37
+ /** A service's running container: its compose stack's, else opctl's. */
38
+ export function findServiceContainer(
39
+ repoName: string,
40
+ repoPath?: string,
41
+ subsystem?: string,
42
+ ): string | null {
43
+ return (
44
+ findStackServiceContainer(repoName, repoPath, subsystem) ??
45
+ findConventionalContainer(repoName, repoPath, subsystem)
46
+ );
47
+ }