@ddtcorex/dsh-maestro-supervisor 0.7.3 → 0.7.5
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.
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface LaunchTarget {
|
|
2
|
+
port: number;
|
|
3
|
+
token: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Parse the newest `dsh web: http://127.0.0.1:<port>/?token=<tok>` boot line
|
|
7
|
+
* from the dsh web log. The launch token and the raw-webserver port (which is
|
|
8
|
+
* 3082 on the local-pin-gate topology, 3080 before it) both live there.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseLaunchTarget(logContent: string): LaunchTarget | undefined;
|
|
11
|
+
export interface MintCookieOpts {
|
|
12
|
+
/** Path to the dsh web log holding the launch line; defaults to `~/.dsh/dsh-web.log`. */
|
|
13
|
+
logPath?: string;
|
|
14
|
+
/** Raw-webserver base URL; overrides the log-derived port (tests). */
|
|
15
|
+
upstreamUrl?: string;
|
|
16
|
+
/** Injectable file reader (tests). */
|
|
17
|
+
readFileImpl?: (path: string) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
export declare function defaultLogPath(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Mint the `dsh-auth-*` session cookie by trading the boot launch token on the
|
|
22
|
+
* raw webserver (index fence: `?token=` -> 303 + Set-Cookie). Returns the
|
|
23
|
+
* `name=value` cookie pair, or undefined when the log is unreadable, no boot
|
|
24
|
+
* line exists, or the exchange does not mint a cookie (upstream 401 etc.).
|
|
25
|
+
*/
|
|
26
|
+
export declare function mintDshSessionCookie(fetchFn: (url: string, init: RequestInit) => Promise<Response>, opts?: MintCookieOpts): Promise<string | undefined>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Parse the newest `dsh web: http://127.0.0.1:<port>/?token=<tok>` boot line
|
|
6
|
+
* from the dsh web log. The launch token and the raw-webserver port (which is
|
|
7
|
+
* 3082 on the local-pin-gate topology, 3080 before it) both live there.
|
|
8
|
+
*/
|
|
9
|
+
export function parseLaunchTarget(logContent) {
|
|
10
|
+
const matches = [...logContent.matchAll(/dsh web: http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9._-]+)/g)];
|
|
11
|
+
const last = matches.at(-1);
|
|
12
|
+
if (last === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
return { port: Number(last[1]), token: last[2] };
|
|
15
|
+
}
|
|
16
|
+
export function defaultLogPath() {
|
|
17
|
+
return join(homedir(), '.dsh', 'dsh-web.log');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Mint the `dsh-auth-*` session cookie by trading the boot launch token on the
|
|
21
|
+
* raw webserver (index fence: `?token=` -> 303 + Set-Cookie). Returns the
|
|
22
|
+
* `name=value` cookie pair, or undefined when the log is unreadable, no boot
|
|
23
|
+
* line exists, or the exchange does not mint a cookie (upstream 401 etc.).
|
|
24
|
+
*/
|
|
25
|
+
export async function mintDshSessionCookie(fetchFn, opts = {}) {
|
|
26
|
+
const read = opts.readFileImpl ?? ((p) => readFile(p, 'utf8'));
|
|
27
|
+
let target;
|
|
28
|
+
try {
|
|
29
|
+
target = parseLaunchTarget(await read(opts.logPath ?? defaultLogPath()));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
target = undefined;
|
|
33
|
+
}
|
|
34
|
+
if (target === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
const upstream = opts.upstreamUrl ?? `http://127.0.0.1:${target.port}`;
|
|
37
|
+
const response = await fetchFn(`${upstream}/?token=${encodeURIComponent(target.token)}`, {
|
|
38
|
+
redirect: 'manual',
|
|
39
|
+
headers: { accept: 'text/html' },
|
|
40
|
+
});
|
|
41
|
+
if (response.status !== 303 && response.status !== 302)
|
|
42
|
+
return undefined;
|
|
43
|
+
const pair = (response.headers.get('set-cookie') ?? '').split(';')[0]?.trim() ?? '';
|
|
44
|
+
return pair.startsWith('dsh-auth-') ? pair : undefined;
|
|
45
|
+
}
|
package/lib/supervisor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HealthState } from './health-poller.js';
|
|
2
2
|
import type { RestartRequest } from './restart-guards.js';
|
|
3
|
+
import { type MintCookieOpts } from './dsh-session.js';
|
|
3
4
|
export interface SupervisorDeps {
|
|
4
5
|
pollHealth: () => Promise<HealthState>;
|
|
5
6
|
writeLKG: () => Promise<{
|
|
@@ -45,7 +46,17 @@ export interface SupervisorDeps {
|
|
|
45
46
|
readRestartRequest?: () => RestartRequest | undefined;
|
|
46
47
|
onRestartRequestHandled?: (req: RestartRequest) => void;
|
|
47
48
|
}
|
|
48
|
-
export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>): Promise<{
|
|
49
|
+
export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>, extraHeaders?: Record<string, string>): Promise<{
|
|
50
|
+
resumed: string[];
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Daemon default resume path: mint the `dsh-auth-*` session cookie first (the
|
|
54
|
+
* `/resume` RPC sits behind the raw webserver's browser-trust fence, which 401s
|
|
55
|
+
* cookie-less loopback calls since the local-pin-gate topology moved the
|
|
56
|
+
* webserver behind the PIN proxies) and attach it to the POST. Falls back to an
|
|
57
|
+
* unauthenticated POST when no boot token is readable — old behavior preserved.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resumeViaRpcWithSession(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>, opts?: MintCookieOpts): Promise<{
|
|
49
60
|
resumed: string[];
|
|
50
61
|
}>;
|
|
51
62
|
export declare class Supervisor {
|
package/lib/supervisor.js
CHANGED
|
@@ -6,12 +6,13 @@ import * as os from 'node:os';
|
|
|
6
6
|
import { resolveHarnessRoot } from './paths.js';
|
|
7
7
|
import { readSupervisorConfig } from './config.js';
|
|
8
8
|
import { writePlannedRestart as defaultWritePlannedRestart, checkPlannedRestart as defaultCheckPlannedRestart, clearPlannedRestart, PLANNED_RESTART_TTL_MS } from './restart-guards.js';
|
|
9
|
+
import { mintDshSessionCookie } from './dsh-session.js';
|
|
9
10
|
import { buildKillStalePortsCommand } from './restart-guards.js';
|
|
10
|
-
export async function resumeViaRpc(ids, fetchFn = globalThis.fetch) {
|
|
11
|
+
export async function resumeViaRpc(ids, fetchFn = globalThis.fetch, extraHeaders = {}) {
|
|
11
12
|
const rpcId = crypto.randomUUID();
|
|
12
13
|
const response = await fetchFn('http://127.0.0.1:3080/dsh-maestro-supervisor-resume/resume', {
|
|
13
14
|
method: 'POST',
|
|
14
|
-
headers: { 'content-type': 'application/json' },
|
|
15
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
15
16
|
body: JSON.stringify({ type: 'client-request', rpcId, method: 'resume', payload: { ids } }),
|
|
16
17
|
});
|
|
17
18
|
if (!response.ok)
|
|
@@ -27,6 +28,23 @@ export async function resumeViaRpc(ids, fetchFn = globalThis.fetch) {
|
|
|
27
28
|
throw new Error('resume RPC returned an invalid result');
|
|
28
29
|
return { resumed };
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Daemon default resume path: mint the `dsh-auth-*` session cookie first (the
|
|
33
|
+
* `/resume` RPC sits behind the raw webserver's browser-trust fence, which 401s
|
|
34
|
+
* cookie-less loopback calls since the local-pin-gate topology moved the
|
|
35
|
+
* webserver behind the PIN proxies) and attach it to the POST. Falls back to an
|
|
36
|
+
* unauthenticated POST when no boot token is readable — old behavior preserved.
|
|
37
|
+
*/
|
|
38
|
+
export async function resumeViaRpcWithSession(ids, fetchFn = globalThis.fetch, opts = {}) {
|
|
39
|
+
let cookie;
|
|
40
|
+
try {
|
|
41
|
+
cookie = await mintDshSessionCookie(fetchFn, opts);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
cookie = undefined;
|
|
45
|
+
}
|
|
46
|
+
return resumeViaRpc(ids, fetchFn, cookie === undefined ? {} : { cookie });
|
|
47
|
+
}
|
|
30
48
|
export class Supervisor {
|
|
31
49
|
deps;
|
|
32
50
|
lastRollback = 0;
|
|
@@ -86,7 +104,7 @@ export class Supervisor {
|
|
|
86
104
|
return this.deps.findInterrupted ?? defaultFindInterrupted;
|
|
87
105
|
}
|
|
88
106
|
getResumeSessions() {
|
|
89
|
-
return this.deps.resumeSessions ??
|
|
107
|
+
return this.deps.resumeSessions ?? resumeViaRpcWithSession;
|
|
90
108
|
}
|
|
91
109
|
getAutoResumeEnabled() {
|
|
92
110
|
// Priority: env > supervisor config.json > maestro settings.json > default true (enabled)
|
package/package.json
CHANGED
|
@@ -8,11 +8,14 @@ compatibility: dsh
|
|
|
8
8
|
|
|
9
9
|
## Purpose
|
|
10
10
|
|
|
11
|
-
One `dsh web` process
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
the
|
|
15
|
-
|
|
11
|
+
One `dsh web` process owns the whole Web surface: the raw webserver on
|
|
12
|
+
`127.0.0.1:3082` (launch-token fence) plus the two Maestro proxies created by
|
|
13
|
+
`dsh-maestro-remote` — the LAN PIN gate on `:3080` (canonical local/LAN URL)
|
|
14
|
+
and the public tunnel proxy on `:3081` — and the `/hooks/gitlab-mr*` webhook
|
|
15
|
+
intake (port 3000 is deprecated/unbound). A host restart drops the live
|
|
16
|
+
socket and the in-flight turn, although sessions rehydrate from the
|
|
17
|
+
append-only log when the browser reconnects. Treat a restart as disruptive:
|
|
18
|
+
validate first and get explicit user consent before a real swap.
|
|
16
19
|
|
|
17
20
|
## Classify the change before touching the live process
|
|
18
21
|
|
|
@@ -30,8 +33,8 @@ explicit user consent before a real swap.
|
|
|
30
33
|
carries the expected marker.
|
|
31
34
|
2. Dry-boot the candidate on an ephemeral port with an isolated `DSH_HOME`.
|
|
32
35
|
Keep the live process and its sessions/settings untouched. If the review
|
|
33
|
-
webhook conflicts on port
|
|
34
|
-
run a no-server composition check instead.
|
|
36
|
+
webhook conflicts on a bound port, exclude that provider for the candidate
|
|
37
|
+
or run a no-server composition check instead.
|
|
35
38
|
3. Verify HTTP 200 and the new marker on the candidate. Retain last-known-good
|
|
36
39
|
assets until the real swap has passed post-swap checks.
|
|
37
40
|
4. Ask for explicit consent and timing. “restart đi” is consent; silence is
|
|
@@ -63,9 +66,10 @@ and refuses to launch if ports are still occupied. `--dry-run` never runs
|
|
|
63
66
|
|
|
64
67
|
Do not read the top of an old append-only log as liveness evidence. Instead:
|
|
65
68
|
|
|
66
|
-
1. Confirm exactly one healthy
|
|
67
|
-
`ss -tlnp
|
|
68
|
-
2. Confirm HTTP 200 from port 3080
|
|
69
|
+
1. Confirm exactly one healthy process owns the listener tree 3080/3081 +
|
|
70
|
+
127.0.0.1:3082 with `ss -tlnp` (port 3000 is no longer bound).
|
|
71
|
+
2. Confirm HTTP 200 from port 3080 (the Maestro PIN login page is 200; the
|
|
72
|
+
app itself loads after the PIN).
|
|
69
73
|
3. Check served bytes contain the **new**, unique marker; or make a fresh
|
|
70
74
|
browser/Playwright probe for the changed UI. Shared third-party markers are
|
|
71
75
|
not sufficient proof.
|
|
@@ -112,8 +112,8 @@ for command in ss ps grep sort; do
|
|
|
112
112
|
command -v "$command" >/dev/null 2>&1 || fail "required command is unavailable: $command"
|
|
113
113
|
done
|
|
114
114
|
|
|
115
|
-
listener_pids="$({ ss -tlnp 2>/dev/null || true; } | grep -E ':(3000|3080)([[:space:]]|$)' | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true)"
|
|
116
|
-
[[ -n "$listener_pids" ]] || fail 'no listeners found on ports 3000 or
|
|
115
|
+
listener_pids="$({ ss -tlnp 2>/dev/null || true; } | grep -E ':(3000|3080|3081|3082)([[:space:]]|$)' | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true)"
|
|
116
|
+
[[ -n "$listener_pids" ]] || fail 'no listeners found on ports 3000/3080 (proxy) or 3081/3082 (local-pin-gate topology)'
|
|
117
117
|
|
|
118
118
|
resolve_tree() {
|
|
119
119
|
local current="$1"
|
|
@@ -250,7 +250,7 @@ else
|
|
|
250
250
|
fi
|
|
251
251
|
fi
|
|
252
252
|
|
|
253
|
-
for port in 3000 3080; do
|
|
253
|
+
for port in 3000 3080 3081 3082; do
|
|
254
254
|
if ss -tln 2>/dev/null | grep -q ":$port "; then
|
|
255
255
|
fail "port $port remains held; refusing to double-boot"
|
|
256
256
|
fi
|
|
@@ -287,12 +287,20 @@ fi
|
|
|
287
287
|
for _ in $(seq 1 90); do
|
|
288
288
|
# 401 is healthy: dsh-web is up but requires the browser token (matches
|
|
289
289
|
# dsh-web-supervisor's own health-poller convention since DSH 0.1.2).
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
290
|
+
# Under the local-pin-gate topology (2026-09-02) :3080 is the maestro PIN
|
|
291
|
+
# proxy (303 -> login / 200 authed) and the raw webserver lives on :3082 —
|
|
292
|
+
# accept any dsh-web surface that answers 200/401/303.
|
|
293
|
+
code_3080="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/ || true)"
|
|
294
|
+
if [[ "$code_3080" == 200 || "$code_3080" == 401 || "$code_3080" == 303 ]]; then
|
|
295
|
+
printf '[restart] DSH Web is serving HTTP %s on port 3080\n' "$code_3080"
|
|
296
|
+
exit 0
|
|
297
|
+
fi
|
|
298
|
+
code_3082="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3082/ || true)"
|
|
299
|
+
if [[ "$code_3082" == 200 || "$code_3082" == 401 ]]; then
|
|
300
|
+
printf '[restart] DSH Web raw webserver is serving HTTP %s on port 3082\n' "$code_3082"
|
|
293
301
|
exit 0
|
|
294
302
|
fi
|
|
295
303
|
sleep 1
|
|
296
304
|
done
|
|
297
305
|
|
|
298
|
-
fail 'DSH Web did not serve HTTP 200/401 on
|
|
306
|
+
fail 'DSH Web did not serve HTTP 200/401/303 on :3080 (PIN proxy) or :3082 (raw webserver) before timeout'
|