@monster-orange/den 0.4.22 → 0.4.23
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 +47 -0
- package/docs/distributions.md +91 -30
- package/package.json +1 -1
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/den-config.ts +27 -12
- package/src/ui/cli/agent-ops.ts +449 -15
- package/src/ui/cli/agents-command.ts +169 -3
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/web-command.ts +32 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +149 -5
- package/src/ui/server/index.ts +13 -5
- package/src/ui/server/public/agents-page.js +214 -22
- package/src/ui/server/public/agents.html +32 -0
- package/src/ui/server/public/i18n/es.js +44 -2
- package/src/ui/server/public/i18n/ja.js +40 -2
- package/src/ui/server/public/settings-registry.js +204 -0
- package/src/ui/server/public/settings.html +43 -4
- package/src/ui/server/public/shell.js +42 -2
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +23 -5
|
@@ -70,6 +70,10 @@ import { HostError } from "../../shared/sessions/client.js";
|
|
|
70
70
|
import { checkHandback } from "../../shared/agents/handback.js";
|
|
71
71
|
import { checkHandoff } from "../../shared/agents/handoff.js";
|
|
72
72
|
import { handoffFile, reasonText } from "../../shared/agents/rotation.js";
|
|
73
|
+
import {
|
|
74
|
+
parseRotateValue,
|
|
75
|
+
type OverrideScope,
|
|
76
|
+
} from "../../shared/agents/rotate-settings.js";
|
|
73
77
|
import {
|
|
74
78
|
agentFilesDir,
|
|
75
79
|
listRecords,
|
|
@@ -85,11 +89,15 @@ import {
|
|
|
85
89
|
listRuntimes,
|
|
86
90
|
loadAgentWorkspaces,
|
|
87
91
|
relaunchAgent,
|
|
92
|
+
resumeOrRelaunch,
|
|
93
|
+
keepAgentConversation,
|
|
94
|
+
askForHandoff,
|
|
95
|
+
rotationSettings,
|
|
96
|
+
setRotationSetting,
|
|
88
97
|
removeAgent,
|
|
89
98
|
removeStopped,
|
|
90
99
|
renameAgent,
|
|
91
100
|
resolveAgentsDeps,
|
|
92
|
-
resumeAgent,
|
|
93
101
|
resumeInterrupted,
|
|
94
102
|
startAgent,
|
|
95
103
|
stopAgent,
|
|
@@ -121,7 +129,7 @@ export {
|
|
|
121
129
|
|
|
122
130
|
export const AGENTS_HELP = {
|
|
123
131
|
usage:
|
|
124
|
-
"agents <list|start|stop|resume|relaunch|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
|
|
132
|
+
"agents <list|start|stop|resume|relaunch|rotate|keep|handoff|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
|
|
125
133
|
lines: [
|
|
126
134
|
"Agents: a persona and a workspace, run by a runtime in a session that",
|
|
127
135
|
"outlives the web server (see `{cli} sessions`).",
|
|
@@ -146,6 +154,19 @@ export const AGENTS_HELP = {
|
|
|
146
154
|
" compactions (default 3) or its pull request merged, and its handoff.md",
|
|
147
155
|
" passes the check, a fresh conversation (same persona, workspace, folder,",
|
|
148
156
|
" grants) whose first prompt is the handoff",
|
|
157
|
+
"{cli} agents rotate [--agent|--persona|--workspace|--service <x>] [-w <ws>] <key> <value>",
|
|
158
|
+
"{cli} agents rotate [scope] --unset <key> · {cli} agents rotate · {cli} agents rotate <id> --show",
|
|
159
|
+
" rotation settings: after_compactions <n>|never (default 3), on_due",
|
|
160
|
+
" auto|ask|off (default auto: Den asks for the handoff at idle and",
|
|
161
|
+
" relaunches by itself), on_pr_merged true|false. No scope: this install.",
|
|
162
|
+
" --persona <p> -w <ws>: the persona in that workspace. The most specific",
|
|
163
|
+
" wins: agent, persona in a workspace, persona, workspace, service,",
|
|
164
|
+
" install, org config. --show: an agent's values and where each comes from",
|
|
165
|
+
"{cli} agents keep <id> [-w <ws>] [--never]",
|
|
166
|
+
" keep its conversation: the compaction count starts again; --never: this",
|
|
167
|
+
" agent never rotates. A person's only",
|
|
168
|
+
"{cli} agents handoff <id> [-w <ws>]",
|
|
169
|
+
" ask a running agent, at idle, to write its handoff",
|
|
149
170
|
"{cli} agents import -w <ws> [--runtime <r>] [--json]",
|
|
150
171
|
"{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
|
|
151
172
|
" bring in a conversation started outside Den: the first lists the runtime's",
|
|
@@ -477,7 +498,15 @@ async function stop(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
477
498
|
async function resume(args: string[], d: AgentsResolved): Promise<number> {
|
|
478
499
|
if (args.includes("--all-interrupted")) return resumeAll(args, d);
|
|
479
500
|
const { id, ws } = idAndWorkspace("agents resume", args);
|
|
480
|
-
|
|
501
|
+
// D28: a due agent under auto relaunches from its handoff instead.
|
|
502
|
+
const r = await resumeOrRelaunch(d, id, ws);
|
|
503
|
+
if (r.kind === "relaunched")
|
|
504
|
+
return report(
|
|
505
|
+
d,
|
|
506
|
+
r.s,
|
|
507
|
+
`relaunched agent ${r.s.rec.id} from its handoff (the last session ended after ${r.s.why}) as ${r.s.rec.sessionName} in ${r.s.rec.cwd}`,
|
|
508
|
+
);
|
|
509
|
+
const s = r.s;
|
|
481
510
|
if (s.fresh) d.out(`agent ${s.rec.id}: ${FRESH_NOTE}`);
|
|
482
511
|
return report(
|
|
483
512
|
d,
|
|
@@ -487,6 +516,135 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
487
516
|
}
|
|
488
517
|
|
|
489
518
|
/** `agents relaunch <id> [-w <ws>]` (den#4). */
|
|
519
|
+
/** D28: refused in an agent's session (an agent could stop its own rotation). */
|
|
520
|
+
function personOnly(where: string, d: AgentsResolved): void {
|
|
521
|
+
if (d.env[ACTOR_TOKEN_ENV] !== undefined)
|
|
522
|
+
throw new AgentError(
|
|
523
|
+
`${where}: only a person can change rotation (this is an agent's session)`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const ROTATE_SCOPE_FLAGS: Record<string, OverrideScope> = {
|
|
528
|
+
"--agent": "agent",
|
|
529
|
+
"--persona": "persona",
|
|
530
|
+
"--workspace": "workspace",
|
|
531
|
+
"--service": "service",
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* `agents rotate` (D28): with no arguments, the settings and overrides;
|
|
536
|
+
* `[--agent|--persona|--workspace|--service <x>] [-w <ws>] <key> <value>`
|
|
537
|
+
* sets one (no scope flag: this install's), `--unset` clears it. `--persona
|
|
538
|
+
* <p> -w <ws>`: the persona in that workspace. `agents rotate <id> --show`:
|
|
539
|
+
* that agent's effective values and where each comes from.
|
|
540
|
+
*/
|
|
541
|
+
async function rotate(args: string[], d: AgentsResolved): Promise<number> {
|
|
542
|
+
const where = "agents rotate";
|
|
543
|
+
if (args.includes("--show")) {
|
|
544
|
+
const { id, ws } = idAndWorkspace(
|
|
545
|
+
where,
|
|
546
|
+
args.filter((a) => a !== "--show"),
|
|
547
|
+
);
|
|
548
|
+
const r = rotationSettings(d, id, ws);
|
|
549
|
+
if (!r.effective) {
|
|
550
|
+
d.err(`✗ ${where}: ${id}'s persona or the config can't be read`);
|
|
551
|
+
return 1;
|
|
552
|
+
}
|
|
553
|
+
const e = r.effective;
|
|
554
|
+
d.out(`agent ${e.agent}:`);
|
|
555
|
+
d.out(
|
|
556
|
+
` after_compactions ${e.afterCompactions === 0 ? "never" : e.afterCompactions} (from ${e.from?.after_compactions})`,
|
|
557
|
+
);
|
|
558
|
+
d.out(` on_due ${e.onDue} (from ${e.from?.on_due})`);
|
|
559
|
+
d.out(
|
|
560
|
+
` on_pr_merged ${e.onPrMerged} (from ${e.from?.on_pr_merged})`,
|
|
561
|
+
);
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
if (!args.length) {
|
|
565
|
+
const r = rotationSettings(d);
|
|
566
|
+
const vals = (v: object) =>
|
|
567
|
+
Object.entries(v)
|
|
568
|
+
.map(([k, x]) => `${k}=${String(x)}`)
|
|
569
|
+
.join(" ") || "(none)";
|
|
570
|
+
d.out(`default: ${vals(r.defaults)}`);
|
|
571
|
+
d.out(`org config: ${vals(r.org)}`);
|
|
572
|
+
d.out(`this install: ${vals(r.install)}`);
|
|
573
|
+
for (const [scope, byTarget] of Object.entries(r.overrides))
|
|
574
|
+
for (const [target, v] of Object.entries(byTarget ?? {}))
|
|
575
|
+
d.out(`${scope} ${target}: ${vals(v)}`);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
personOnly(where, d);
|
|
579
|
+
const unset = args.includes("--unset");
|
|
580
|
+
const rest = args.filter((a) => a !== "--unset");
|
|
581
|
+
let scope: OverrideScope | "install" = "install";
|
|
582
|
+
let target: string | undefined;
|
|
583
|
+
let ws: string | undefined;
|
|
584
|
+
const pos: string[] = [];
|
|
585
|
+
for (let i = 0; i < rest.length; i++) {
|
|
586
|
+
const a = rest[i];
|
|
587
|
+
if (a === "-w" || a === "--ws") {
|
|
588
|
+
ws = rest[++i];
|
|
589
|
+
if (!ws) throw new UsageError(`${where}: ${a} needs a workspace`);
|
|
590
|
+
} else if (a in ROTATE_SCOPE_FLAGS) {
|
|
591
|
+
if (scope !== "install")
|
|
592
|
+
throw new UsageError(`${where}: one scope at a time`);
|
|
593
|
+
scope = ROTATE_SCOPE_FLAGS[a];
|
|
594
|
+
target = rest[++i];
|
|
595
|
+
if (!target) throw new UsageError(`${where}: ${a} needs a name`);
|
|
596
|
+
} else if (a.startsWith("--"))
|
|
597
|
+
throw new UsageError(`${where}: unknown flag ${a}`);
|
|
598
|
+
else pos.push(a);
|
|
599
|
+
}
|
|
600
|
+
if (ws) {
|
|
601
|
+
if (scope !== "persona")
|
|
602
|
+
throw new UsageError(
|
|
603
|
+
`${where}: -w goes with --persona (the persona in that workspace); use --workspace <ws> for a workspace`,
|
|
604
|
+
);
|
|
605
|
+
scope = "persona-workspace";
|
|
606
|
+
target = `${target}@${ws}`;
|
|
607
|
+
}
|
|
608
|
+
const [key, raw] = pos;
|
|
609
|
+
if (!key || (unset ? pos.length !== 1 : pos.length !== 2))
|
|
610
|
+
throw new UsageError(
|
|
611
|
+
`${where} [--agent|--persona|--workspace|--service <x>] <key> <value> (or --unset <key>)`,
|
|
612
|
+
);
|
|
613
|
+
setRotationSetting(d, {
|
|
614
|
+
scope,
|
|
615
|
+
...(target !== undefined ? { target } : {}),
|
|
616
|
+
key,
|
|
617
|
+
value: unset ? null : parseRotateValue(key, raw!),
|
|
618
|
+
});
|
|
619
|
+
const at = scope === "install" ? "this install" : `${scope} ${target}`;
|
|
620
|
+
d.out(unset ? `${key} cleared at ${at}` : `${key} = ${raw} at ${at}`);
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** `agents keep <id> [-w <ws>] [--never]` (D28 Keep this conversation). */
|
|
625
|
+
function keep(args: string[], d: AgentsResolved): number {
|
|
626
|
+
const where = "agents keep";
|
|
627
|
+
personOnly(where, d);
|
|
628
|
+
const never = args.includes("--never");
|
|
629
|
+
const { id, ws } = idAndWorkspace(
|
|
630
|
+
where,
|
|
631
|
+
args.filter((a) => a !== "--never"),
|
|
632
|
+
);
|
|
633
|
+
const rec = keepAgentConversation(d, id, ws, { never });
|
|
634
|
+
d.out(
|
|
635
|
+
`kept ${rec.id}'s conversation: its compaction count starts again${never ? "; it never rotates (agents rotate --agent " + rec.id + " --unset after_compactions to undo)" : ""}`,
|
|
636
|
+
);
|
|
637
|
+
return 0;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** `agents handoff <id> [-w <ws>]` (D28 Write handoff). */
|
|
641
|
+
async function handoff(args: string[], d: AgentsResolved): Promise<number> {
|
|
642
|
+
const { id, ws } = idAndWorkspace("agents handoff", args);
|
|
643
|
+
const rec = await askForHandoff(d, id, ws);
|
|
644
|
+
d.out(`asked ${rec.id} to write its handoff; Den relaunches it from it`);
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
|
|
490
648
|
async function relaunch(args: string[], d: AgentsResolved): Promise<number> {
|
|
491
649
|
const { id, ws } = idAndWorkspace("agents relaunch", args);
|
|
492
650
|
const s = await relaunchAgent(d, id, ws);
|
|
@@ -790,6 +948,14 @@ export async function cmdAgents(
|
|
|
790
948
|
case "relaunch":
|
|
791
949
|
await loadAgentWorkspaces(deps);
|
|
792
950
|
return await relaunch(rest, d);
|
|
951
|
+
case "rotate":
|
|
952
|
+
await loadAgentWorkspaces(deps);
|
|
953
|
+
return await rotate(rest, d);
|
|
954
|
+
case "keep":
|
|
955
|
+
return keep(rest, d);
|
|
956
|
+
case "handoff":
|
|
957
|
+
await loadAgentWorkspaces(deps);
|
|
958
|
+
return await handoff(rest, d);
|
|
793
959
|
case "import":
|
|
794
960
|
await loadAgentWorkspaces(deps);
|
|
795
961
|
return await bringIn(rest, d);
|
|
@@ -25,9 +25,9 @@ import {
|
|
|
25
25
|
type AgentsResolved,
|
|
26
26
|
type RuntimeChoice,
|
|
27
27
|
type Started,
|
|
28
|
+
rotationOf,
|
|
28
29
|
} from "./agent-ops.js";
|
|
29
30
|
import { readRecord } from "../../shared/agents/records.js";
|
|
30
|
-
import { rotationInfo } from "../../shared/agents/rotation.js";
|
|
31
31
|
import {
|
|
32
32
|
SERVER_AGENT_ID,
|
|
33
33
|
SERVER_PERSONA_ID,
|
|
@@ -355,7 +355,7 @@ export async function startServerAgent(
|
|
|
355
355
|
rec.runtime === runtime &&
|
|
356
356
|
rec.model === model
|
|
357
357
|
) {
|
|
358
|
-
if (
|
|
358
|
+
if (rotationOf(d, rec)?.ready)
|
|
359
359
|
return done("relaunched", await relaunchAgent(d, rec.id));
|
|
360
360
|
if (resumeRefusal(rec) === null)
|
|
361
361
|
return done("resumed", await resumeAgent(d, rec.id, SERVER_SCOPE));
|
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
} from "./server-identity.js";
|
|
71
71
|
|
|
72
72
|
import { product } from "../../shared/product.js";
|
|
73
|
+
import { webServiceAction, type WebServiceDeps } from "./web-service.js";
|
|
73
74
|
import {
|
|
74
75
|
CLAIM_CODE_TTL_MS,
|
|
75
76
|
claimLink,
|
|
@@ -1265,6 +1266,12 @@ export function cmdWebToken(
|
|
|
1265
1266
|
return 0;
|
|
1266
1267
|
}
|
|
1267
1268
|
|
|
1269
|
+
/** Tests: the launchd service seams (web-service.ts); never the real launchctl. */
|
|
1270
|
+
let webServiceDeps: WebServiceDeps | undefined;
|
|
1271
|
+
export function setWebServiceDepsForTest(d: WebServiceDeps | undefined): void {
|
|
1272
|
+
webServiceDeps = d;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1268
1275
|
export async function cmdWeb(args: string[]): Promise<void> {
|
|
1269
1276
|
// A subcommand with its own help answers its own --help (it is
|
|
1270
1277
|
// dispatched first); the rest (up, down, restart, open) share this one.
|
|
@@ -1314,7 +1321,26 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1314
1321
|
const identity = await refuseForeignServer(options.port, ownServer());
|
|
1315
1322
|
const vouched = identity.kind === "ours" ? identity.pid : undefined;
|
|
1316
1323
|
|
|
1317
|
-
|
|
1324
|
+
// With the launchd service installed, it owns the server: restart and up
|
|
1325
|
+
// go through launchd, down is refused (launchd would start it again).
|
|
1326
|
+
const svc = await webServiceAction(
|
|
1327
|
+
options.action,
|
|
1328
|
+
options.port,
|
|
1329
|
+
identity.kind === "ours",
|
|
1330
|
+
(p) => waitForDenWeb(p),
|
|
1331
|
+
webServiceDeps,
|
|
1332
|
+
);
|
|
1333
|
+
if (svc.handled) {
|
|
1334
|
+
if (svc.code !== 0) {
|
|
1335
|
+
process.exitCode = svc.code;
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
recordRunningDenWebServer(options.port);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (svc.handled) {
|
|
1342
|
+
/* the service runs it: on to opening the browser */
|
|
1343
|
+
} else if (options.action === "down") {
|
|
1318
1344
|
const stopped = await stopDenWebServer(options.port, vouched);
|
|
1319
1345
|
if (stopped.stopped) {
|
|
1320
1346
|
const mode = stopped.forced ? "force stopped" : "stopped";
|
|
@@ -1343,7 +1369,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1343
1369
|
if (line) console.log(chalk.dim(`\n ${line}`));
|
|
1344
1370
|
}
|
|
1345
1371
|
|
|
1346
|
-
if (options.action === "restart") {
|
|
1372
|
+
if (options.action === "restart" && !svc.handled) {
|
|
1347
1373
|
const stopped = await stopDenWebServer(options.port, vouched);
|
|
1348
1374
|
if (stopped.pids.length > 0 && !stopped.stopped) {
|
|
1349
1375
|
throw new Error(
|
|
@@ -1369,8 +1395,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1369
1395
|
);
|
|
1370
1396
|
if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
|
|
1371
1397
|
|
|
1372
|
-
const apiRunning = await isDenWebApiRunning(options.port);
|
|
1373
|
-
let running =
|
|
1398
|
+
const apiRunning = svc.handled || (await isDenWebApiRunning(options.port));
|
|
1399
|
+
let running =
|
|
1400
|
+
svc.handled || (apiRunning && (await isDenWebUiReady(options.port)));
|
|
1374
1401
|
let started: StartedServer | null = null;
|
|
1375
1402
|
|
|
1376
1403
|
if (apiRunning && !running) {
|
|
@@ -1415,7 +1442,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1415
1442
|
console.log(chalk.red(` ERROR Failed to start ${product().cli} web`));
|
|
1416
1443
|
throw error;
|
|
1417
1444
|
}
|
|
1418
|
-
} else {
|
|
1445
|
+
} else if (!svc.handled) {
|
|
1419
1446
|
recordRunningDenWebServer(options.port);
|
|
1420
1447
|
console.log(
|
|
1421
1448
|
chalk.green(`\n OK ${product().title} web is already running at ${url}`),
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web up | down | restart` when this install's web server runs as a
|
|
3
|
+
* launchd service (`<cli> service install`).
|
|
4
|
+
*
|
|
5
|
+
* Before, `web restart` stopped the job's server and started a plain one.
|
|
6
|
+
* launchd brought the job straight back (KeepAlive), it took the port, and
|
|
7
|
+
* the plain server died seconds after "OK ... started". Now, with the
|
|
8
|
+
* service installed and the command on its port:
|
|
9
|
+
*
|
|
10
|
+
* - restart: `launchctl kickstart -k`, then the job's new pid and the
|
|
11
|
+
* service's log;
|
|
12
|
+
* - up: a job that is not answering is started the same way;
|
|
13
|
+
* - down: refused, because launchd would only start it again; it names
|
|
14
|
+
* `<cli> service uninstall`.
|
|
15
|
+
*
|
|
16
|
+
* In always mode the job is in the system domain: kickstart there needs
|
|
17
|
+
* sudo, so the command is printed for the person to run, never run here.
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "child_process";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import {
|
|
23
|
+
readDenConfig,
|
|
24
|
+
readWebServiceConfig,
|
|
25
|
+
} from "../../shared/den-config.js";
|
|
26
|
+
import { readWebPort } from "../../shared/edge/config.js";
|
|
27
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
28
|
+
import { product } from "../../shared/product.js";
|
|
29
|
+
import { serviceJobState, type ServiceJob } from "./doctor-where.js";
|
|
30
|
+
|
|
31
|
+
export interface WebService {
|
|
32
|
+
label: string;
|
|
33
|
+
/** The service's web.log. */
|
|
34
|
+
log: string;
|
|
35
|
+
job: ServiceJob;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WebServiceDeps {
|
|
39
|
+
/** The installed service for this install's port, or null. */
|
|
40
|
+
service?: (port: number) => WebService | null;
|
|
41
|
+
/** launchctl kickstart [-k] <domain>/<label>; its exit code. */
|
|
42
|
+
kickstart?: (target: string, kill: boolean) => number;
|
|
43
|
+
/** The pid launchd runs the job as, or null. */
|
|
44
|
+
jobPid?: (svc: WebService) => number | null;
|
|
45
|
+
/** Until the job's server answers as this install's (true) or time runs out. */
|
|
46
|
+
waitReady?: (port: number) => Promise<boolean>;
|
|
47
|
+
log?: (line: string) => void;
|
|
48
|
+
/** Between pid checks while the job comes back (default 250 ms). */
|
|
49
|
+
pollMs?: number;
|
|
50
|
+
/** How long to wait for the job's new pid (default 30 s). */
|
|
51
|
+
pidTimeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WebServiceResult =
|
|
55
|
+
| { handled: false }
|
|
56
|
+
| { handled: true; code: number; pid?: number };
|
|
57
|
+
|
|
58
|
+
function defaultService(port: number): WebService | null {
|
|
59
|
+
if (process.platform !== "darwin") return null;
|
|
60
|
+
const distRoot = getDistRoot();
|
|
61
|
+
// The service serves this install's configured port only.
|
|
62
|
+
if (port !== readWebPort(distRoot)) return null;
|
|
63
|
+
const { label, logs } = readWebServiceConfig(
|
|
64
|
+
readDenConfig(),
|
|
65
|
+
distRoot,
|
|
66
|
+
os.homedir(),
|
|
67
|
+
);
|
|
68
|
+
const job = serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
|
|
69
|
+
if (!job || job.state !== "loaded") return null;
|
|
70
|
+
return { label, log: path.join(logs, "web.log"), job };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function defaultKickstart(target: string, kill: boolean): number {
|
|
74
|
+
return (
|
|
75
|
+
spawnSync("launchctl", ["kickstart", ...(kill ? ["-k"] : []), target], {
|
|
76
|
+
stdio: "ignore",
|
|
77
|
+
timeout: 10_000,
|
|
78
|
+
}).status ?? 1
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultJobPid(svc: WebService): number | null {
|
|
83
|
+
const r = spawnSync(
|
|
84
|
+
"launchctl",
|
|
85
|
+
["print", `${svc.job.domain}/${svc.label}`],
|
|
86
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
87
|
+
);
|
|
88
|
+
if (r.status !== 0) return null;
|
|
89
|
+
const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
|
|
90
|
+
return m ? Number(m[1]) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What `web <action>` does when the service is installed. `answering`:
|
|
97
|
+
* this install's server already answers on the port. Not handled: no
|
|
98
|
+
* service (or another port), and `up` while it answers.
|
|
99
|
+
*/
|
|
100
|
+
export async function webServiceAction(
|
|
101
|
+
action: "up" | "down" | "restart" | "open",
|
|
102
|
+
port: number,
|
|
103
|
+
answering: boolean,
|
|
104
|
+
waitReadyDefault: (port: number) => Promise<boolean>,
|
|
105
|
+
deps: WebServiceDeps = {},
|
|
106
|
+
): Promise<WebServiceResult> {
|
|
107
|
+
if (action === "open") return { handled: false };
|
|
108
|
+
const svc = (deps.service ?? defaultService)(port);
|
|
109
|
+
if (!svc) return { handled: false };
|
|
110
|
+
if (action === "up" && answering) return { handled: false };
|
|
111
|
+
const log = deps.log ?? ((l: string) => console.log(l));
|
|
112
|
+
const { cli, title } = product();
|
|
113
|
+
const target = `${svc.job.domain}/${svc.label}`;
|
|
114
|
+
|
|
115
|
+
if (action === "down") {
|
|
116
|
+
log(
|
|
117
|
+
`✗ ${title} web runs as the service ${svc.label} (${svc.job.mode}): stopping it only makes launchd start it again.`,
|
|
118
|
+
);
|
|
119
|
+
log(` To stop it for good: ${cli} service uninstall`);
|
|
120
|
+
log(` To restart it: ${cli} web restart`);
|
|
121
|
+
return { handled: true, code: 1 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (svc.job.mode === "always") {
|
|
125
|
+
log(
|
|
126
|
+
`${title} web runs as the service ${svc.label} (always). Restarting it needs sudo:`,
|
|
127
|
+
);
|
|
128
|
+
log(` sudo launchctl kickstart -k ${target}`);
|
|
129
|
+
return { handled: true, code: 1 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const jobPid = deps.jobPid ?? defaultJobPid;
|
|
133
|
+
const before = jobPid(svc);
|
|
134
|
+
const code = (deps.kickstart ?? defaultKickstart)(target, true);
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
log(`✗ launchctl kickstart -k ${target} failed (exit ${code})`);
|
|
137
|
+
log(` See ${svc.log}`);
|
|
138
|
+
return { handled: true, code: 1 };
|
|
139
|
+
}
|
|
140
|
+
// A new pid, then the server answering.
|
|
141
|
+
const deadline = Date.now() + (deps.pidTimeoutMs ?? 30_000);
|
|
142
|
+
let pid = jobPid(svc);
|
|
143
|
+
while ((pid === null || pid === before) && Date.now() < deadline) {
|
|
144
|
+
await sleep(deps.pollMs ?? 250);
|
|
145
|
+
pid = jobPid(svc);
|
|
146
|
+
}
|
|
147
|
+
const ready =
|
|
148
|
+
pid !== null &&
|
|
149
|
+
pid !== before &&
|
|
150
|
+
(await (deps.waitReady ?? waitReadyDefault)(port));
|
|
151
|
+
if (!ready) {
|
|
152
|
+
log(`✗ the service ${svc.label} did not come back. See ${svc.log}`);
|
|
153
|
+
return { handled: true, code: 1 };
|
|
154
|
+
}
|
|
155
|
+
log(
|
|
156
|
+
`OK ${title} web ${action === "up" ? "started" : "restarted"} by its service ${svc.label} (pid ${pid})`,
|
|
157
|
+
);
|
|
158
|
+
log(` Log: ${svc.log}`);
|
|
159
|
+
return { handled: true, code: 0, pid: pid! };
|
|
160
|
+
}
|