@monster-orange/den 0.2.5 → 0.2.7
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 +215 -0
- package/bin/den +9 -0
- package/docs/distributions.md +202 -22
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/den-config.ts +38 -1
- package/src/shared/environment-manager/catalog.ts +9 -2
- package/src/shared/environment-manager/default-workspace.ts +92 -14
- package/src/shared/environment-manager/health.ts +96 -17
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +2 -1
- package/src/shared/environment-manager/workspace-org.ts +264 -0
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +106 -14
- package/src/shared/stacks/runtime.ts +2 -0
- package/src/shared/types/index.ts +20 -1
- package/src/ui/cli/clone-plan.ts +243 -0
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/index.ts +274 -61
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/org-command.ts +162 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/cli/web-command.ts +76 -25
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +30 -2
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +63 -18
- package/src/ui/server/release-service.ts +16 -3
- package/src/ui/server/workspace-routes.ts +4 -1
package/src/ui/mcp/api.ts
CHANGED
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
* server holds no state of its own: agents and the web UI share the server's
|
|
4
4
|
* state and its guards. This client can reach only the routes below, and it
|
|
5
5
|
* sends the agent's audit headers (see shared/agent-actor.ts) on every call.
|
|
6
|
+
*
|
|
7
|
+
* With `identity`, it first makes sure the server on the port is this
|
|
8
|
+
* distribution's (server-identity.ts): another distribution's server (or an
|
|
9
|
+
* older Den from another root) on the configured port gets no request, and
|
|
10
|
+
* every tool says whose server it is instead of serving its data.
|
|
6
11
|
*/
|
|
7
12
|
import { product } from "../../shared/product.js";
|
|
13
|
+
import {
|
|
14
|
+
foreignServerMessage,
|
|
15
|
+
identifyServer,
|
|
16
|
+
type OwnServer,
|
|
17
|
+
type ServerIdentity,
|
|
18
|
+
} from "../cli/server-identity.js";
|
|
8
19
|
import { LIFECYCLE_CMDS, Refusal } from "./guards.js";
|
|
9
20
|
|
|
10
21
|
export type Query = Record<string, string | string[] | undefined>;
|
|
@@ -20,6 +31,47 @@ export class DenWebDown extends Error {
|
|
|
20
31
|
}
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
/** Another distribution's server answers on the port: no data from it. */
|
|
35
|
+
export class ForeignWebServer extends Error {
|
|
36
|
+
constructor(
|
|
37
|
+
readonly port: number,
|
|
38
|
+
readonly who: string,
|
|
39
|
+
) {
|
|
40
|
+
super(
|
|
41
|
+
`${foreignServerMessage(port, who)}; not this distribution's server. ` +
|
|
42
|
+
`Nothing was read from it. Point web.port (or --port) at this distribution's server.`,
|
|
43
|
+
);
|
|
44
|
+
this.name = "ForeignWebServer";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Something answers on the port but would not say whose server it is. */
|
|
49
|
+
export class UnidentifiedWebServer extends Error {
|
|
50
|
+
constructor(readonly port: number) {
|
|
51
|
+
super(
|
|
52
|
+
`the server on port ${port} did not answer /api/server-info in time, ` +
|
|
53
|
+
`so it is not known to be this distribution's; nothing was read from it. Try again.`,
|
|
54
|
+
);
|
|
55
|
+
this.name = "UnidentifiedWebServer";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How long a positive identity check is trusted. */
|
|
60
|
+
export const IDENTITY_TTL_MS = 30_000;
|
|
61
|
+
/** The identity probe's budget: longer than the CLI's, a busy server is ours too. */
|
|
62
|
+
export const IDENTITY_TIMEOUT_MS = 5_000;
|
|
63
|
+
|
|
64
|
+
export interface IdentityCheck {
|
|
65
|
+
/** This distribution: its root and product. */
|
|
66
|
+
own: OwnServer;
|
|
67
|
+
/** Default identifyServer. */
|
|
68
|
+
identify?: (port: number, own: OwnServer) => Promise<ServerIdentity>;
|
|
69
|
+
/** Default IDENTITY_TTL_MS. */
|
|
70
|
+
ttlMs?: number;
|
|
71
|
+
/** Default Date.now. */
|
|
72
|
+
now?: () => number;
|
|
73
|
+
}
|
|
74
|
+
|
|
23
75
|
/** The web server answered with an error status. */
|
|
24
76
|
export class DenHttpError extends Error {
|
|
25
77
|
constructor(
|
|
@@ -157,8 +209,44 @@ export function createDenApi(opts: {
|
|
|
157
209
|
baseUrl: string;
|
|
158
210
|
cli: string;
|
|
159
211
|
headers: () => Record<string, string>;
|
|
212
|
+
/** Check whose server it is before any request (the MCP server does). */
|
|
213
|
+
identity?: IdentityCheck;
|
|
160
214
|
}): DenApi {
|
|
161
215
|
const { baseUrl, cli } = opts;
|
|
216
|
+
const { hostname } = new URL(baseUrl);
|
|
217
|
+
const port = Number(new URL(baseUrl).port) || 80;
|
|
218
|
+
let vouchedAt: number | null = null;
|
|
219
|
+
let checking: Promise<void> | null = null;
|
|
220
|
+
|
|
221
|
+
/** Throws unless the server on the port is ours (cached briefly). */
|
|
222
|
+
async function ensureOurs(): Promise<void> {
|
|
223
|
+
const check = opts.identity;
|
|
224
|
+
if (!check) return;
|
|
225
|
+
const now = check.now ?? Date.now;
|
|
226
|
+
if (
|
|
227
|
+
vouchedAt !== null &&
|
|
228
|
+
now() - vouchedAt < (check.ttlMs ?? IDENTITY_TTL_MS)
|
|
229
|
+
)
|
|
230
|
+
return;
|
|
231
|
+
checking ??= (async () => {
|
|
232
|
+
const id = await (
|
|
233
|
+
check.identify ??
|
|
234
|
+
((p: number, o: OwnServer) =>
|
|
235
|
+
identifyServer(p, o, IDENTITY_TIMEOUT_MS, hostname))
|
|
236
|
+
)(port, check.own);
|
|
237
|
+
if (id.kind === "ours") {
|
|
238
|
+
vouchedAt = now();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
vouchedAt = null;
|
|
242
|
+
if (id.kind === "foreign") throw new ForeignWebServer(port, id.who);
|
|
243
|
+
if (id.kind === "none") throw new DenWebDown(baseUrl, cli);
|
|
244
|
+
throw new UnidentifiedWebServer(port);
|
|
245
|
+
})().finally(() => {
|
|
246
|
+
checking = null;
|
|
247
|
+
});
|
|
248
|
+
return checking;
|
|
249
|
+
}
|
|
162
250
|
|
|
163
251
|
async function request(
|
|
164
252
|
method: string,
|
|
@@ -168,6 +256,7 @@ export function createDenApi(opts: {
|
|
|
168
256
|
body?: unknown,
|
|
169
257
|
): Promise<Response> {
|
|
170
258
|
assertAllowedRequest(method, path, query);
|
|
259
|
+
await ensureOurs();
|
|
171
260
|
try {
|
|
172
261
|
const res = await fetch(buildUrl(baseUrl, path, query), {
|
|
173
262
|
method,
|
|
@@ -184,7 +273,11 @@ export function createDenApi(opts: {
|
|
|
184
273
|
}
|
|
185
274
|
return res;
|
|
186
275
|
} catch (e) {
|
|
187
|
-
if (isConnectionError(e))
|
|
276
|
+
if (isConnectionError(e)) {
|
|
277
|
+
// Whatever answers next on the port is checked again.
|
|
278
|
+
vouchedAt = null;
|
|
279
|
+
throw new DenWebDown(baseUrl, cli);
|
|
280
|
+
}
|
|
188
281
|
throw e;
|
|
189
282
|
}
|
|
190
283
|
}
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* stdout carries the protocol only: diagnostics go to stderr.
|
|
7
7
|
*/
|
|
8
|
+
import { z } from "zod";
|
|
8
9
|
import { randomUUID } from "crypto";
|
|
9
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -18,7 +19,9 @@ import { engineVersion } from "../../shared/paths.js";
|
|
|
18
19
|
import { product } from "../../shared/product.js";
|
|
19
20
|
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
20
21
|
import { createDenApi, type DenApi } from "./api.js";
|
|
22
|
+
import { ownServer } from "../cli/server-identity.js";
|
|
21
23
|
import { callTool, toolsFor, type ToolContext } from "./tools.js";
|
|
24
|
+
import { plural } from "../../shared/plural.js";
|
|
22
25
|
|
|
23
26
|
export interface DenMcpOptions {
|
|
24
27
|
cfg: ResolvedMcpConfig;
|
|
@@ -56,6 +59,8 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
56
59
|
createDenApi({
|
|
57
60
|
baseUrl: opts.baseUrl,
|
|
58
61
|
cli: opts.cli,
|
|
62
|
+
// Serve nothing from another distribution's server on the port.
|
|
63
|
+
identity: { own: ownServer() },
|
|
59
64
|
headers: () =>
|
|
60
65
|
agentAuditHeaders({
|
|
61
66
|
client: client()?.name ?? "unknown",
|
|
@@ -181,7 +186,29 @@ export async function runOneShot(
|
|
|
181
186
|
return 0;
|
|
182
187
|
}
|
|
183
188
|
const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
|
|
184
|
-
|
|
189
|
+
// The stdio server validates arguments against each tool's schema (the
|
|
190
|
+
// SDK does it); a one-shot must too, and strictly: it is typed by hand, so
|
|
191
|
+
// an unknown or misspelt key is an error, not silently dropped.
|
|
192
|
+
let args = shot.args;
|
|
193
|
+
const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
|
|
194
|
+
if (tool) {
|
|
195
|
+
const parsed = z.object(tool.shape).strict().safeParse(args);
|
|
196
|
+
if (!parsed.success) {
|
|
197
|
+
const err = opts.err ?? ((t: string) => process.stderr.write(t));
|
|
198
|
+
const lines = parsed.error.issues.map((i) => {
|
|
199
|
+
const at = i.path.length ? i.path.join(".") : "(arguments)";
|
|
200
|
+
return ` ${at}: ${i.message}`;
|
|
201
|
+
});
|
|
202
|
+
const keys = Object.keys(tool.shape);
|
|
203
|
+
err(
|
|
204
|
+
`--call ${shot.tool}: invalid arguments\n${lines.join("\n")}\n` +
|
|
205
|
+
` takes: ${keys.length ? keys.join(", ") : "no arguments"}\n`,
|
|
206
|
+
);
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
args = parsed.data as Record<string, unknown>;
|
|
210
|
+
}
|
|
211
|
+
const r = await callTool(shot.tool, args, ctx);
|
|
185
212
|
out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
|
|
186
213
|
return r.isError ? 1 : 0;
|
|
187
214
|
}
|
|
@@ -210,6 +237,7 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
210
237
|
const api = createDenApi({
|
|
211
238
|
baseUrl,
|
|
212
239
|
cli,
|
|
240
|
+
identity: { own: ownServer() },
|
|
213
241
|
headers: () =>
|
|
214
242
|
agentAuditHeaders({
|
|
215
243
|
client: `${cli} mcp --call`,
|
|
@@ -224,7 +252,7 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
224
252
|
const server = createDenMcpServer({ cfg, baseUrl, cli });
|
|
225
253
|
await server.connect(new StdioServerTransport());
|
|
226
254
|
process.stderr.write(
|
|
227
|
-
`${cli} mcp: ${toolsFor(cfg).length}
|
|
255
|
+
`${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
|
|
228
256
|
`(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
|
|
229
257
|
);
|
|
230
258
|
}
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { redactSecrets } from "../../shared/redact.js";
|
|
|
14
14
|
import {
|
|
15
15
|
DenHttpError,
|
|
16
16
|
DenWebDown,
|
|
17
|
+
ForeignWebServer,
|
|
18
|
+
UnidentifiedWebServer,
|
|
17
19
|
type DenApi,
|
|
18
20
|
type Query,
|
|
19
21
|
type StreamResult,
|
|
@@ -154,7 +156,7 @@ const READ_TOOLS: DenTool[] = [
|
|
|
154
156
|
read(
|
|
155
157
|
"status",
|
|
156
158
|
"Service health",
|
|
157
|
-
"Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command.",
|
|
159
|
+
"Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command or a container check (`container <project>/<service>`).",
|
|
158
160
|
{ env: envArg, workspace: workspaceArg },
|
|
159
161
|
(a, { api }) =>
|
|
160
162
|
api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
|
|
@@ -636,7 +638,9 @@ export async function callTool(
|
|
|
636
638
|
const msg =
|
|
637
639
|
e instanceof Refusal
|
|
638
640
|
? `Refused: ${e.message}`
|
|
639
|
-
: e instanceof DenWebDown
|
|
641
|
+
: e instanceof DenWebDown ||
|
|
642
|
+
e instanceof ForeignWebServer ||
|
|
643
|
+
e instanceof UnidentifiedWebServer
|
|
640
644
|
? e.message
|
|
641
645
|
: `Error: ${(e as Error).message}`;
|
|
642
646
|
return { text: redactSecrets(msg), isError: true };
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* so an "error" health result is only acted on after a SECOND
|
|
12
12
|
* consecutive "error" check separated by a retry delay. "unknown"
|
|
13
13
|
* results and thrown checks always keep the entry (err on the side of
|
|
14
|
-
* keeping). A daemon whose address no longer resolves
|
|
15
|
-
* workspace/catalog service is process-death-equivalent evidence and
|
|
16
|
-
* removed directly.
|
|
14
|
+
* keeping). A daemon this root registered whose address no longer resolves
|
|
15
|
+
* to any workspace/catalog service is process-death-equivalent evidence and
|
|
16
|
+
* is removed directly. Records another root registered (all roots of a
|
|
17
|
+
* product share the file) are never touched, and an unstamped legacy record
|
|
18
|
+
* is not removed for being unresolvable.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
@@ -41,6 +43,12 @@ export interface DaemonReaperDeps {
|
|
|
41
43
|
delay: (ms: number) => Promise<void>;
|
|
42
44
|
/** Gap between the first "error" result and the confirming re-check. */
|
|
43
45
|
retryDelayMs: number;
|
|
46
|
+
/**
|
|
47
|
+
* Which root wrote the record (`daemonRootOf`). Every root of a product
|
|
48
|
+
* shares one daemons file: another root's records are never swept here,
|
|
49
|
+
* and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
|
|
50
|
+
*/
|
|
51
|
+
rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface DaemonReapResult {
|
|
@@ -92,9 +100,19 @@ export async function reapDeadDaemons(
|
|
|
92
100
|
await Promise.allSettled(
|
|
93
101
|
deps.listDaemons().map(async (daemon) => {
|
|
94
102
|
try {
|
|
103
|
+
const root = deps.rootOf(daemon);
|
|
104
|
+
if (root === "other") {
|
|
105
|
+
kept.push(daemon.address);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
95
108
|
const service = resolveForDaemon(deps, daemon);
|
|
96
109
|
if (!service) {
|
|
97
|
-
// No longer in any owning workspace/catalog — nothing could restart
|
|
110
|
+
// No longer in any owning workspace/catalog — nothing could restart
|
|
111
|
+
// it. An unstamped record may be another root's, so it stays.
|
|
112
|
+
if (root === "unstamped") {
|
|
113
|
+
kept.push(daemon.address);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
98
116
|
deps.unregisterDaemon(daemon.address);
|
|
99
117
|
removed.push(daemon.address);
|
|
100
118
|
return;
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The fields of a /api/health row that say what was checked. `url` is only
|
|
3
3
|
* ever a URL (or null): a shell check's command (a compose stack's
|
|
4
|
-
* `<cli> stack health …`, or any declared shell check) goes in `check
|
|
4
|
+
* `<cli> stack health …`, or any declared shell check) goes in `check`, as
|
|
5
|
+
* does a container check's `container <project>/<service>` (or
|
|
6
|
+
* `container <name>`).
|
|
5
7
|
*/
|
|
6
|
-
export type HealthCheckType = "shell" | "http" | "none";
|
|
8
|
+
export type HealthCheckType = "shell" | "http" | "container" | "none";
|
|
7
9
|
|
|
8
10
|
export function healthCheckFields(
|
|
9
11
|
checkType: HealthCheckType,
|
|
10
12
|
target: string,
|
|
11
13
|
): { url: string | null; check?: string } {
|
|
12
|
-
if (checkType === "shell"
|
|
14
|
+
if (checkType === "shell" || checkType === "container")
|
|
15
|
+
return { url: null, check: target };
|
|
13
16
|
if (checkType === "http") return { url: target };
|
|
14
17
|
return { url: null };
|
|
15
18
|
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
composeHealthTarget,
|
|
15
|
+
containerCheckLabel,
|
|
15
16
|
type ComposeHealthTarget,
|
|
17
|
+
type ContainerHealthTarget,
|
|
16
18
|
} from "../../shared/stacks/compose-health.js";
|
|
17
|
-
import { healthCheckFields } from "./health-row.js";
|
|
19
|
+
import { healthCheckFields, type HealthCheckType } from "./health-row.js";
|
|
18
20
|
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
19
21
|
import * as http from "http";
|
|
20
22
|
import * as fs from "fs";
|
|
@@ -62,6 +64,8 @@ import {
|
|
|
62
64
|
isSafeServiceName,
|
|
63
65
|
} from "./log-history.js";
|
|
64
66
|
import {
|
|
67
|
+
containerTarget,
|
|
68
|
+
pingContainerCheck,
|
|
65
69
|
pingHttp,
|
|
66
70
|
pingShellCheck,
|
|
67
71
|
TimeoutStreaks,
|
|
@@ -76,6 +80,7 @@ import {
|
|
|
76
80
|
checkServiceHealth,
|
|
77
81
|
loadDaemonsFromFile,
|
|
78
82
|
pruneDeadDaemonsAsync,
|
|
83
|
+
daemonRootOf,
|
|
79
84
|
isInteractiveServiceCommand,
|
|
80
85
|
rewriteLegacyOpctlKillCommandForContainer,
|
|
81
86
|
shouldDetachServiceCommand,
|
|
@@ -432,6 +437,11 @@ import {
|
|
|
432
437
|
send404,
|
|
433
438
|
} from "./http-helpers.js";
|
|
434
439
|
import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
|
|
440
|
+
import {
|
|
441
|
+
readOrgContext,
|
|
442
|
+
resolveAllWorkspaceOrgs,
|
|
443
|
+
withOrgMembership,
|
|
444
|
+
} from "../../shared/environment-manager/workspace-org.js";
|
|
435
445
|
import {
|
|
436
446
|
readLatestSummary,
|
|
437
447
|
securityReportsDir,
|
|
@@ -680,9 +690,18 @@ async function handleRequest(
|
|
|
680
690
|
|
|
681
691
|
// ── API: environments ──────────────────────────────────────────────────────
|
|
682
692
|
// Orgs the catalog's repos belong to, with config/den.yml display names.
|
|
693
|
+
// Each entry also says whether it is the default org (`github_org`) and
|
|
694
|
+
// which workspaces on this machine belong to it (ORG-1a). Nothing else
|
|
695
|
+
// changes: same entries, order and fields (ORG-SPEC C3).
|
|
683
696
|
if (pathname === "/api/orgs") {
|
|
684
|
-
const
|
|
685
|
-
const
|
|
697
|
+
const ctx = readOrgContext();
|
|
698
|
+
const catalog = ctx.catalog;
|
|
699
|
+
const orgs = withOrgMembership(
|
|
700
|
+
listOrgs(catalog, ctx.config.orgs ?? {}),
|
|
701
|
+
resolveAllWorkspaceOrgs(),
|
|
702
|
+
ctx.file,
|
|
703
|
+
ctx.config,
|
|
704
|
+
);
|
|
686
705
|
if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
|
|
687
706
|
const repos = Object.fromEntries(
|
|
688
707
|
Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
|
|
@@ -926,7 +945,7 @@ async function handleRequest(
|
|
|
926
945
|
repo: string;
|
|
927
946
|
service: string;
|
|
928
947
|
label: string;
|
|
929
|
-
checkType:
|
|
948
|
+
checkType: HealthCheckType;
|
|
930
949
|
target: string;
|
|
931
950
|
repoPath: string;
|
|
932
951
|
mode: "dev-managed" | "managed";
|
|
@@ -941,6 +960,8 @@ async function handleRequest(
|
|
|
941
960
|
expect?: Expect;
|
|
942
961
|
/** pingShell options (Den's own `stack health` can report unknown). */
|
|
943
962
|
compose: ComposeHealthTarget | null;
|
|
963
|
+
/** A `type: container` check: answered from the shared listing. */
|
|
964
|
+
container: ContainerHealthTarget | null;
|
|
944
965
|
meta: Pick<HealthResult, "runs" | "target">;
|
|
945
966
|
}> = [];
|
|
946
967
|
let edgeDomain: string | null = null;
|
|
@@ -974,6 +995,7 @@ async function handleRequest(
|
|
|
974
995
|
links: [],
|
|
975
996
|
org: orgOf(resolved.repoName, catalog),
|
|
976
997
|
compose: null,
|
|
998
|
+
container: null,
|
|
977
999
|
meta: { runs: "none", target: null },
|
|
978
1000
|
});
|
|
979
1001
|
continue;
|
|
@@ -1020,14 +1042,16 @@ async function handleRequest(
|
|
|
1020
1042
|
service: resolved.subsystem,
|
|
1021
1043
|
label: plan.label ?? healthCfg?.label ?? resolved.subsystem,
|
|
1022
1044
|
checkType: healthCfg
|
|
1023
|
-
? healthCfg.type === "shell"
|
|
1024
|
-
?
|
|
1045
|
+
? healthCfg.type === "shell" || healthCfg.type === "container"
|
|
1046
|
+
? healthCfg.type
|
|
1025
1047
|
: "http"
|
|
1026
1048
|
: "none",
|
|
1027
1049
|
target: healthCfg
|
|
1028
1050
|
? healthCfg.type === "shell"
|
|
1029
1051
|
? healthCfg.cmd
|
|
1030
|
-
: healthCfg.
|
|
1052
|
+
: healthCfg.type === "container"
|
|
1053
|
+
? containerCheckLabel(containerTarget(healthCfg))
|
|
1054
|
+
: healthCfg.url
|
|
1031
1055
|
: "",
|
|
1032
1056
|
repoPath: resolved.repoPath,
|
|
1033
1057
|
mode: resolved.mode,
|
|
@@ -1048,14 +1072,15 @@ async function handleRequest(
|
|
|
1048
1072
|
subsystem: resolved.subsystem,
|
|
1049
1073
|
repoName: resolved.repoName,
|
|
1050
1074
|
env,
|
|
1051
|
-
healthUrl:
|
|
1052
|
-
healthCfg && healthCfg.type !== "shell" ? healthCfg.url : null,
|
|
1075
|
+
healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
|
|
1053
1076
|
edgeDomain,
|
|
1054
1077
|
}),
|
|
1055
1078
|
links: linksFor(manifest, resolved.subsystem, env),
|
|
1056
1079
|
org: orgOf(resolved.repoName, catalog),
|
|
1057
1080
|
...(plan.expect ? { expect: plan.expect } : {}),
|
|
1058
1081
|
compose,
|
|
1082
|
+
container:
|
|
1083
|
+
healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
|
|
1059
1084
|
meta: { runs: plan.placement.runs, target: plan.placement.target },
|
|
1060
1085
|
});
|
|
1061
1086
|
}
|
|
@@ -1086,6 +1111,7 @@ async function handleRequest(
|
|
|
1086
1111
|
meta,
|
|
1087
1112
|
expect,
|
|
1088
1113
|
compose,
|
|
1114
|
+
container,
|
|
1089
1115
|
}) => {
|
|
1090
1116
|
if (checkType !== "none") {
|
|
1091
1117
|
const started = Date.now();
|
|
@@ -1097,13 +1123,15 @@ async function handleRequest(
|
|
|
1097
1123
|
repoPath,
|
|
1098
1124
|
compose,
|
|
1099
1125
|
)
|
|
1100
|
-
:
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1126
|
+
: checkType === "container" && container
|
|
1127
|
+
? await pingContainerCheck(container)
|
|
1128
|
+
: await pingHttp(
|
|
1129
|
+
target,
|
|
1130
|
+
env === "local" && meta.runs !== "remote"
|
|
1131
|
+
? HEALTH_CHECK_TIMEOUT_MS
|
|
1132
|
+
: HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
1133
|
+
expect,
|
|
1134
|
+
);
|
|
1107
1135
|
return {
|
|
1108
1136
|
repo,
|
|
1109
1137
|
service,
|
|
@@ -2362,7 +2390,14 @@ async function handleRequest(
|
|
|
2362
2390
|
// ── API: den config ────────────────────────────────────────────────────
|
|
2363
2391
|
if (pathname === `/api/${product().cli}/config` && req.method === "GET") {
|
|
2364
2392
|
// `org` resolved (id defaults to edge.org) for the header's org label.
|
|
2365
|
-
|
|
2393
|
+
const config = readDenConfig();
|
|
2394
|
+
// `default_org` (ORG-1a) is the default work org, `github_org`; `org`
|
|
2395
|
+
// stays the instance org (readOrg).
|
|
2396
|
+
return sendJson(res, {
|
|
2397
|
+
...config,
|
|
2398
|
+
org: readOrg(),
|
|
2399
|
+
default_org: config.github_org?.trim() || null,
|
|
2400
|
+
});
|
|
2366
2401
|
}
|
|
2367
2402
|
|
|
2368
2403
|
// ── API: server info + staleness (drives the header "stale server" pill) ────
|
|
@@ -2373,6 +2408,11 @@ async function handleRequest(
|
|
|
2373
2408
|
port: PORT,
|
|
2374
2409
|
// Den's version: the MCP server compares it with the routes it needs.
|
|
2375
2410
|
version: engineVersion() ?? null,
|
|
2411
|
+
// Whose server this is: `<cli> web up|down`, `mcp connect` and
|
|
2412
|
+
// `service install` compare both with their own and never act on
|
|
2413
|
+
// another distribution's server (see cli/server-identity.ts).
|
|
2414
|
+
root: ROOT,
|
|
2415
|
+
product: product().name,
|
|
2376
2416
|
startedAt: new Date(SERVER_STARTED_AT).toISOString(),
|
|
2377
2417
|
uptimeMs: Date.now() - SERVER_STARTED_AT,
|
|
2378
2418
|
stale,
|
|
@@ -3166,7 +3206,7 @@ void (async () => {
|
|
|
3166
3206
|
await new Promise<void>((resolve) =>
|
|
3167
3207
|
setTimeout(resolve, DAEMON_SHUTDOWN_GRACE_MS),
|
|
3168
3208
|
);
|
|
3169
|
-
await reapDeadDaemons({
|
|
3209
|
+
const reaped = await reapDeadDaemons({
|
|
3170
3210
|
listDaemons,
|
|
3171
3211
|
resolveService,
|
|
3172
3212
|
getActiveWorkspaces,
|
|
@@ -3174,7 +3214,12 @@ void (async () => {
|
|
|
3174
3214
|
unregisterDaemon,
|
|
3175
3215
|
delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
3176
3216
|
retryDelayMs: DAEMON_RETRY_DELAY_MS,
|
|
3217
|
+
rootOf: daemonRootOf,
|
|
3177
3218
|
});
|
|
3219
|
+
if (reaped.removed.length > 0)
|
|
3220
|
+
console.error(
|
|
3221
|
+
`Reaped ${reaped.removed.length} stopped daemon(s): ${reaped.removed.join(", ")}`,
|
|
3222
|
+
);
|
|
3178
3223
|
})();
|
|
3179
3224
|
|
|
3180
3225
|
const prunedSnapshots = pruneDeadSnapshots();
|
|
@@ -19,13 +19,16 @@ import { execFile } from "child_process";
|
|
|
19
19
|
import { promisify } from "util";
|
|
20
20
|
import { GH_API_TIMEOUT_MS } from "../../shared/constants.js";
|
|
21
21
|
import {
|
|
22
|
+
readExplicitReleaseOrg,
|
|
22
23
|
readReleaseConfig,
|
|
24
|
+
releaseOwnerFor,
|
|
23
25
|
type ReleaseConfig,
|
|
24
26
|
type ReleaseRepo,
|
|
25
27
|
type ResolvedReleaseTarget,
|
|
26
28
|
resolveReleaseTarget,
|
|
27
29
|
} from "../../shared/den-config.js";
|
|
28
30
|
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
31
|
+
import { orgOf } from "../../shared/manifest/service-links.js";
|
|
29
32
|
import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
|
|
30
33
|
|
|
31
34
|
import { product } from "../../shared/product.js";
|
|
@@ -262,13 +265,23 @@ export class ReleaseService {
|
|
|
262
265
|
};
|
|
263
266
|
}
|
|
264
267
|
|
|
265
|
-
|
|
266
|
-
|
|
268
|
+
/**
|
|
269
|
+
* The owner a repo is released from (REL-1): an explicit `release.org`
|
|
270
|
+
* (den.yml or user.yml) wins; otherwise the repo's own non-empty catalog
|
|
271
|
+
* owner, so a client org's repo releases from its own org; `github_org`
|
|
272
|
+
* (readReleaseConfig's fallback for `cfg.org`) last.
|
|
273
|
+
*/
|
|
274
|
+
private ownerFor(repo: string, cfg: ReleaseConfig): string {
|
|
275
|
+
return releaseOwnerFor({
|
|
276
|
+
explicitOrg: readExplicitReleaseOrg(),
|
|
277
|
+
catalogOwner: orgOf(repo, readCatalog()),
|
|
278
|
+
githubOrg: cfg.org,
|
|
279
|
+
});
|
|
267
280
|
}
|
|
268
281
|
|
|
269
282
|
slug(repo: string, cfg = this.config()): string {
|
|
270
283
|
const gh = cfg.repos.find((r) => r.name === repo)?.gh;
|
|
271
|
-
return gh && gh.includes("/") ? gh : `${this.
|
|
284
|
+
return gh && gh.includes("/") ? gh : `${this.ownerFor(repo, cfg)}/${repo}`;
|
|
272
285
|
}
|
|
273
286
|
|
|
274
287
|
/**
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
getWaitDeps,
|
|
66
66
|
} from "../../shared/environment-manager/dag.js";
|
|
67
67
|
import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
|
|
68
|
+
import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
|
|
68
69
|
import {
|
|
69
70
|
deriveStartTargets,
|
|
70
71
|
filterClosureForStart,
|
|
@@ -2158,7 +2159,9 @@ export async function handleWorkspaceRoutes(
|
|
|
2158
2159
|
// flow writes one), which used to read as "not in the catalog" and
|
|
2159
2160
|
// skip the clone entirely. Derive the conventional slug instead, and
|
|
2160
2161
|
// persist it so every other flow (CLI up/setup) stops hitting this.
|
|
2161
|
-
const ghSlug = resolveRepoGhSlug(svc.repoName)
|
|
2162
|
+
const ghSlug = resolveRepoGhSlug(svc.repoName, readCatalog(), () =>
|
|
2163
|
+
githubOrgFor(ws.name),
|
|
2164
|
+
);
|
|
2162
2165
|
if (!readCatalog()[svc.repoName]?.gh) {
|
|
2163
2166
|
upsertCatalogEntry(svc.repoName, { gh: ghSlug });
|
|
2164
2167
|
}
|