@monster-orange/den 0.2.4 → 0.2.6
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 +195 -0
- package/docs/distributions.md +158 -16
- package/package.json +1 -1
- 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/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +229 -87
- package/src/shared/environment-manager/health.ts +235 -35
- 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 +242 -0
- package/src/shared/stacks/runtime.ts +73 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +86 -41
- package/src/ui/cli/mcp-connect.ts +36 -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/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +101 -32
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +120 -40
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +7 -0
|
@@ -3,6 +3,7 @@ import { execFileSync, spawn, spawnSync } from "child_process";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as http from "http";
|
|
5
5
|
import * as path from "path";
|
|
6
|
+
import { pathToFileURL } from "url";
|
|
6
7
|
import {
|
|
7
8
|
CLI_SPAWN_TIMEOUT_MS,
|
|
8
9
|
DEFAULT_PORT,
|
|
@@ -10,8 +11,14 @@ import {
|
|
|
10
11
|
} from "../../shared/constants.js";
|
|
11
12
|
import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
|
|
12
13
|
import { hasDenUiMarker } from "../../shared/ui-marker.js";
|
|
13
|
-
import { engineBin } from "../../shared/engine.js";
|
|
14
|
+
import { engineBin, engineModuleFile } from "../../shared/engine.js";
|
|
14
15
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
16
|
+
import {
|
|
17
|
+
ownServer,
|
|
18
|
+
processCwd,
|
|
19
|
+
refuseForeignServer,
|
|
20
|
+
samePath,
|
|
21
|
+
} from "./server-identity.js";
|
|
15
22
|
|
|
16
23
|
import { product } from "../../shared/product.js";
|
|
17
24
|
interface WebCommandOptions {
|
|
@@ -294,8 +301,19 @@ async function waitForDenWeb(port: number): Promise<boolean> {
|
|
|
294
301
|
return false;
|
|
295
302
|
}
|
|
296
303
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
304
|
+
/**
|
|
305
|
+
* How to run the web server: Den's own files, from the engine root.
|
|
306
|
+
*
|
|
307
|
+
* The TypeScript source runs as `node --import <tsx loader> index.ts`, never
|
|
308
|
+
* through the `tsx` CLI. The CLI is a parent process that relays signals to
|
|
309
|
+
* the server and, if the server has not acknowledged a SIGTERM within about
|
|
310
|
+
* 60ms (two 30ms windows), SIGKILLs it and exits 143. Under load the server
|
|
311
|
+
* misses that window, so `<cli> web down` killed it without its shutdown.
|
|
312
|
+
* With the loader there is one process, and the signal reaches the
|
|
313
|
+
* server's own handler. (The launchd service, service/install.sh, already
|
|
314
|
+
* runs this way.)
|
|
315
|
+
*/
|
|
316
|
+
export function resolveServerEntrypoint(engine: string = getEngineRoot()): {
|
|
299
317
|
cmd: string;
|
|
300
318
|
args: string[];
|
|
301
319
|
} {
|
|
@@ -313,17 +331,23 @@ function resolveServerEntrypoint(engine: string = getEngineRoot()): {
|
|
|
313
331
|
// take effect. The server resolves PUBLIC_DIR relative to its own module, so
|
|
314
332
|
// running from src/ serves src/ui/server/public directly. Fall back to the
|
|
315
333
|
// compiled build only when devDependencies (tsx) aren't installed.
|
|
316
|
-
const
|
|
334
|
+
const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
|
|
317
335
|
const srcServer = path.join(engine, "src/ui/server/index.ts");
|
|
318
|
-
if (
|
|
319
|
-
return {
|
|
336
|
+
if (loader && fs.existsSync(srcServer)) {
|
|
337
|
+
return {
|
|
338
|
+
cmd: process.execPath,
|
|
339
|
+
args: ["--import", pathToFileURL(loader).href, srcServer],
|
|
340
|
+
};
|
|
320
341
|
}
|
|
321
342
|
|
|
322
343
|
if (fs.existsSync(builtServer)) {
|
|
323
344
|
return { cmd: process.execPath, args: [builtServer] };
|
|
324
345
|
}
|
|
325
346
|
|
|
326
|
-
return {
|
|
347
|
+
return {
|
|
348
|
+
cmd: engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx", engine),
|
|
349
|
+
args: [srcServer],
|
|
350
|
+
};
|
|
327
351
|
}
|
|
328
352
|
|
|
329
353
|
function startDenWebServer(options: WebCommandOptions): StartedServer {
|
|
@@ -551,21 +575,55 @@ async function waitForPidsExit(pids: number[]): Promise<boolean> {
|
|
|
551
575
|
return false;
|
|
552
576
|
}
|
|
553
577
|
|
|
554
|
-
|
|
578
|
+
/**
|
|
579
|
+
* Whether `pid` may be this distribution's server by where it runs: a web
|
|
580
|
+
* server runs from its distribution root, so one whose working directory
|
|
581
|
+
* is another root is never ours, even when its command line matches (two
|
|
582
|
+
* distributions on one linked engine run the same entry point). An
|
|
583
|
+
* unreadable directory does not decide.
|
|
584
|
+
*/
|
|
585
|
+
function runsFromRoot(pid: number, root: string): boolean {
|
|
586
|
+
const cwd = processCwd(pid);
|
|
587
|
+
if (!cwd) return true;
|
|
588
|
+
return samePath(cwd, root);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function isOurWebPid(pid: number, root: string): boolean {
|
|
592
|
+
const command = commandForPid(pid);
|
|
593
|
+
return (
|
|
594
|
+
!!command &&
|
|
595
|
+
isDenWebCommand(command, webRoots(root)) &&
|
|
596
|
+
runsFromRoot(pid, root)
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Our server's pids on `port`. `vouched` is the pid of a server the
|
|
602
|
+
* identity check (server-info: our root, or an older server run from our
|
|
603
|
+
* root) found ours: it is stopped when it listens on the port even if its
|
|
604
|
+
* command line names no entry point we recognise. Before, an older Den
|
|
605
|
+
* installed elsewhere, or a hand-started server, matched no command line,
|
|
606
|
+
* so `web down` said "not running" and `web restart` "already running".
|
|
607
|
+
*/
|
|
608
|
+
function findDenWebPids(port: number, vouched?: number): number[] {
|
|
555
609
|
const root = getDistRoot();
|
|
556
610
|
const pids = new Set<number>();
|
|
557
611
|
const state = readWebServerState();
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
612
|
+
const listeners = listListeningPids(port);
|
|
613
|
+
// The state file may be shared by distributions of one product name: a
|
|
614
|
+
// record of another root's server is not ours to stop.
|
|
615
|
+
if (
|
|
616
|
+
state?.port === port &&
|
|
617
|
+
state.pid &&
|
|
618
|
+
samePath(state.root || root, root) &&
|
|
619
|
+
(isOurWebPid(state.pid, root) ||
|
|
620
|
+
(state.pid === vouched && listeners.includes(state.pid)))
|
|
621
|
+
) {
|
|
622
|
+
pids.add(state.pid);
|
|
563
623
|
}
|
|
564
624
|
|
|
565
|
-
for (const pid of
|
|
566
|
-
if (
|
|
567
|
-
pids.add(pid);
|
|
568
|
-
}
|
|
625
|
+
for (const pid of listeners) {
|
|
626
|
+
if (pid === vouched || isOurWebPid(pid, root)) pids.add(pid);
|
|
569
627
|
}
|
|
570
628
|
|
|
571
629
|
return Array.from(pids);
|
|
@@ -593,8 +651,11 @@ export function killWebProcess(pid: number, signal: NodeJS.Signals): void {
|
|
|
593
651
|
}
|
|
594
652
|
}
|
|
595
653
|
|
|
596
|
-
async function stopDenWebServer(
|
|
597
|
-
|
|
654
|
+
async function stopDenWebServer(
|
|
655
|
+
port: number,
|
|
656
|
+
vouched?: number,
|
|
657
|
+
): Promise<StopServerResult> {
|
|
658
|
+
const pids = findDenWebPids(port, vouched);
|
|
598
659
|
if (pids.length === 0) {
|
|
599
660
|
const state = readWebServerState();
|
|
600
661
|
if (state?.port === port) removeWebServerState();
|
|
@@ -618,22 +679,25 @@ async function stopDenWebServer(port: number): Promise<StopServerResult> {
|
|
|
618
679
|
return { stopped: forced, forced: true, pids };
|
|
619
680
|
}
|
|
620
681
|
|
|
621
|
-
async function stopBrokenDenWebServer(
|
|
622
|
-
|
|
682
|
+
async function stopBrokenDenWebServer(
|
|
683
|
+
port: number,
|
|
684
|
+
vouched?: number,
|
|
685
|
+
): Promise<boolean> {
|
|
686
|
+
return (await stopDenWebServer(port, vouched)).stopped;
|
|
623
687
|
}
|
|
624
688
|
|
|
625
689
|
function recordRunningDenWebServer(port: number): void {
|
|
626
690
|
const root = getDistRoot();
|
|
627
691
|
const state = readWebServerState();
|
|
628
|
-
if (
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
692
|
+
if (
|
|
693
|
+
state?.port === port &&
|
|
694
|
+
state.pid &&
|
|
695
|
+
samePath(state.root || root, root) &&
|
|
696
|
+
isOurWebPid(state.pid, root)
|
|
697
|
+
)
|
|
698
|
+
return;
|
|
633
699
|
|
|
634
|
-
const pids = listListeningPids(port).filter((pid) =>
|
|
635
|
-
isDenWebCommand(commandForPid(pid), webRoots(root)),
|
|
636
|
-
);
|
|
700
|
+
const pids = listListeningPids(port).filter((pid) => isOurWebPid(pid, root));
|
|
637
701
|
const pid = pids[0];
|
|
638
702
|
if (!pid) return;
|
|
639
703
|
|
|
@@ -947,8 +1011,13 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
947
1011
|
|
|
948
1012
|
const url = buildDenWebUrl(options.port);
|
|
949
1013
|
|
|
1014
|
+
// Never act on another distribution's server: not "already running", and
|
|
1015
|
+
// never a signal to it (down, restart).
|
|
1016
|
+
const identity = await refuseForeignServer(options.port, ownServer());
|
|
1017
|
+
const vouched = identity.kind === "ours" ? identity.pid : undefined;
|
|
1018
|
+
|
|
950
1019
|
if (options.action === "down") {
|
|
951
|
-
const stopped = await stopDenWebServer(options.port);
|
|
1020
|
+
const stopped = await stopDenWebServer(options.port, vouched);
|
|
952
1021
|
if (stopped.stopped) {
|
|
953
1022
|
const mode = stopped.forced ? "force stopped" : "stopped";
|
|
954
1023
|
console.log(
|
|
@@ -970,7 +1039,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
970
1039
|
}
|
|
971
1040
|
|
|
972
1041
|
if (options.action === "restart") {
|
|
973
|
-
const stopped = await stopDenWebServer(options.port);
|
|
1042
|
+
const stopped = await stopDenWebServer(options.port, vouched);
|
|
974
1043
|
if (stopped.pids.length > 0 && !stopped.stopped) {
|
|
975
1044
|
throw new Error(
|
|
976
1045
|
`Could not stop ${product().title} web on port ${options.port}.`,
|
|
@@ -997,7 +1066,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
997
1066
|
),
|
|
998
1067
|
);
|
|
999
1068
|
console.log(chalk.gray(` Restarting ${product().title} web...`));
|
|
1000
|
-
const stopped = await stopBrokenDenWebServer(options.port);
|
|
1069
|
+
const stopped = await stopBrokenDenWebServer(options.port, vouched);
|
|
1001
1070
|
if (!stopped) {
|
|
1002
1071
|
throw new Error(
|
|
1003
1072
|
`Could not stop the broken ${product().title} web server on port ${options.port}.`,
|
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
|
@@ -18,7 +18,9 @@ import { engineVersion } from "../../shared/paths.js";
|
|
|
18
18
|
import { product } from "../../shared/product.js";
|
|
19
19
|
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
20
20
|
import { createDenApi, type DenApi } from "./api.js";
|
|
21
|
+
import { ownServer } from "../cli/server-identity.js";
|
|
21
22
|
import { callTool, toolsFor, type ToolContext } from "./tools.js";
|
|
23
|
+
import { plural } from "../../shared/plural.js";
|
|
22
24
|
|
|
23
25
|
export interface DenMcpOptions {
|
|
24
26
|
cfg: ResolvedMcpConfig;
|
|
@@ -56,6 +58,8 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
56
58
|
createDenApi({
|
|
57
59
|
baseUrl: opts.baseUrl,
|
|
58
60
|
cli: opts.cli,
|
|
61
|
+
// Serve nothing from another distribution's server on the port.
|
|
62
|
+
identity: { own: ownServer() },
|
|
59
63
|
headers: () =>
|
|
60
64
|
agentAuditHeaders({
|
|
61
65
|
client: client()?.name ?? "unknown",
|
|
@@ -210,6 +214,7 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
210
214
|
const api = createDenApi({
|
|
211
215
|
baseUrl,
|
|
212
216
|
cli,
|
|
217
|
+
identity: { own: ownServer() },
|
|
213
218
|
headers: () =>
|
|
214
219
|
agentAuditHeaders({
|
|
215
220
|
client: `${cli} mcp --call`,
|
|
@@ -224,7 +229,7 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
224
229
|
const server = createDenMcpServer({ cfg, baseUrl, cli });
|
|
225
230
|
await server.connect(new StdioServerTransport());
|
|
226
231
|
process.stderr.write(
|
|
227
|
-
`${cli} mcp: ${toolsFor(cfg).length}
|
|
232
|
+
`${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
|
|
228
233
|
`(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
|
|
229
234
|
);
|
|
230
235
|
}
|
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 };
|
|
@@ -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
|
}
|