@lelouchhe/webagent 0.2.6 → 0.4.0
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/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/config.js
CHANGED
|
@@ -1,26 +1,137 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { parse as parseTOML } from "smol-toml";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
const ConfigSchema = z.object({
|
|
4
|
+
export const ConfigSchema = z.object({
|
|
5
5
|
port: z.number().int().positive().default(6800),
|
|
6
6
|
data_dir: z.string().default("data"),
|
|
7
7
|
default_cwd: z.string().default(process.cwd()),
|
|
8
8
|
public_dir: z.string().default("dist"),
|
|
9
|
-
agent_cmd: z.string().default("
|
|
10
|
-
limits: z
|
|
9
|
+
agent_cmd: z.string().default("auto"),
|
|
10
|
+
limits: z
|
|
11
|
+
.object({
|
|
11
12
|
bash_output: z.number().int().positive().default(1_048_576), // 1 MB
|
|
12
13
|
image_upload: z.number().int().positive().default(10_485_760), // 10 MB
|
|
14
|
+
file_upload: z.number().int().positive().default(52_428_800), // 50 MB — non-image attachments
|
|
13
15
|
cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
|
|
14
|
-
|
|
16
|
+
recent_paths: z.number().int().nonnegative().default(10), // /new menu display limit; 0 = show all
|
|
17
|
+
recent_paths_ttl: z.number().int().nonnegative().default(30), // days before auto-cleanup; 0 = keep forever
|
|
18
|
+
})
|
|
19
|
+
.default({
|
|
15
20
|
bash_output: 1_048_576,
|
|
16
21
|
image_upload: 10_485_760,
|
|
22
|
+
file_upload: 52_428_800,
|
|
17
23
|
cancel_timeout: 10_000,
|
|
24
|
+
recent_paths: 10,
|
|
25
|
+
recent_paths_ttl: 30,
|
|
18
26
|
}),
|
|
19
|
-
push: z
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
push: z
|
|
28
|
+
.object({
|
|
29
|
+
vapid_subject: z.string().default("mailto:noreply@example.com"),
|
|
30
|
+
global_visibility_suppression: z.boolean().default(true),
|
|
31
|
+
})
|
|
32
|
+
.default({
|
|
33
|
+
vapid_subject: "mailto:noreply@example.com",
|
|
34
|
+
global_visibility_suppression: true,
|
|
23
35
|
}),
|
|
36
|
+
// [title] — title generation sub-session configuration.
|
|
37
|
+
//
|
|
38
|
+
// `model` is an array of case-insensitive substring patterns. When the
|
|
39
|
+
// title sub-session is created, we look at the model list the agent
|
|
40
|
+
// reports (ACP `availableModels`) and pick the first model whose id
|
|
41
|
+
// matches any pattern in order. Match → call `setConfigOption` with
|
|
42
|
+
// that model id; no match → skip the call and inherit the agent's
|
|
43
|
+
// default model (`currentModelId`).
|
|
44
|
+
//
|
|
45
|
+
// Default list targets the cheap/fast tier across major providers:
|
|
46
|
+
// - "haiku" → Anthropic (claude-haiku-*)
|
|
47
|
+
// - "flash-lite" → Google Gemini (gemini-*-flash-lite) [must precede "flash"]
|
|
48
|
+
// - "nano" → OpenAI (gpt-*-nano), Gemini Nano
|
|
49
|
+
// - "mini" → OpenAI (gpt-*-mini, 4o-mini), Mistral
|
|
50
|
+
// - "flash" → Google Gemini (gemini-*-flash)
|
|
51
|
+
// - "lite" → Cohere, generic
|
|
52
|
+
//
|
|
53
|
+
// Set `model = []` to disable substring matching entirely and always
|
|
54
|
+
// inherit the agent's default model. To pin one specific model, pass a
|
|
55
|
+
// single-element array: `model = ["claude-haiku-4.5"]`.
|
|
56
|
+
title: z
|
|
57
|
+
.object({
|
|
58
|
+
model: z
|
|
59
|
+
.array(z.string())
|
|
60
|
+
.default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
|
|
61
|
+
})
|
|
62
|
+
.default({
|
|
63
|
+
model: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
|
|
64
|
+
}),
|
|
65
|
+
// [debug] — frontend log level.
|
|
66
|
+
// level ∈ off | debug | info | warn | error. Default "off".
|
|
67
|
+
// Users can override per page-load via `?debug=<level>` in the URL,
|
|
68
|
+
// or at runtime via the /log slash command.
|
|
69
|
+
debug: z
|
|
70
|
+
.object({
|
|
71
|
+
level: z.enum(["off", "debug", "info", "warn", "error"]).default("off"),
|
|
72
|
+
})
|
|
73
|
+
.default({ level: "off" }),
|
|
74
|
+
// [messages] — external notifications primitive.
|
|
75
|
+
// `unprocessed_ttl_days` caps how long an unbound message stays in the
|
|
76
|
+
// inbox before TTL cleanup removes it. 0 = keep forever.
|
|
77
|
+
messages: z
|
|
78
|
+
.object({
|
|
79
|
+
unprocessed_ttl_days: z.number().int().nonnegative().default(30),
|
|
80
|
+
})
|
|
81
|
+
.default({ unprocessed_ttl_days: 30 }),
|
|
82
|
+
// [share] — public read-only session share links.
|
|
83
|
+
// Default: disabled. Dogfood manually flips `enabled = true` after
|
|
84
|
+
// CF Access bypass + Rate Limiting are configured. See docs/share.md.
|
|
85
|
+
// enabled — master kill switch; when false, all share routes
|
|
86
|
+
// return 410 and slash commands are hidden.
|
|
87
|
+
// ttl_hours — global default TTL for public share links. 0 =
|
|
88
|
+
// never expire (default). >0 is clamped to 168 (7d).
|
|
89
|
+
// Per-share override via `shares.ttl_hours` column.
|
|
90
|
+
// csp_enforce — true (default) emits Content-Security-Policy on
|
|
91
|
+
// /s/* and /api/v1/shared/* routes. false emits
|
|
92
|
+
// Content-Security-Policy-Report-Only for rollback.
|
|
93
|
+
// viewer_origin — public viewer URL host; empty string = same as
|
|
94
|
+
// webagent host (default). Useful if viewer is
|
|
95
|
+
// behind a different CF Worker route (e.g.
|
|
96
|
+
// "https://share.example.com").
|
|
97
|
+
// internal_hosts — sanitizer internal-domain allowlist; any token
|
|
98
|
+
// matching these substrings gets rewritten to
|
|
99
|
+
// `<internal-host>` before publishing.
|
|
100
|
+
share: z
|
|
101
|
+
.object({
|
|
102
|
+
enabled: z.boolean().default(false),
|
|
103
|
+
ttl_hours: z.number().int().nonnegative().default(0),
|
|
104
|
+
csp_enforce: z.boolean().default(true),
|
|
105
|
+
viewer_origin: z.string().default(""),
|
|
106
|
+
internal_hosts: z.array(z.string()).default([]),
|
|
107
|
+
})
|
|
108
|
+
.default({
|
|
109
|
+
enabled: false,
|
|
110
|
+
ttl_hours: 0,
|
|
111
|
+
csp_enforce: true,
|
|
112
|
+
viewer_origin: "",
|
|
113
|
+
internal_hosts: [],
|
|
114
|
+
}),
|
|
115
|
+
// [auth] — bearer-token auth knobs.
|
|
116
|
+
// first_run_bootstrap controls the zero-config first-run UX:
|
|
117
|
+
// true (default) — when auth.json file does NOT exist AND stdin
|
|
118
|
+
// is a TTY, server auto-mints a one-time admin
|
|
119
|
+
// token and prints it as part of the startup-
|
|
120
|
+
// doctor stream. Operator copies the token from
|
|
121
|
+
// terminal scrollback and pastes it into the
|
|
122
|
+
// /login form.
|
|
123
|
+
// false — refuse to serve, print `--create-token` hint,
|
|
124
|
+
// exit 78. Use this when deploying behind a
|
|
125
|
+
// supervisor that provisions auth.json
|
|
126
|
+
// out-of-band (CI, Ansible, k8s init container).
|
|
127
|
+
// Either way: if auth.json exists but list is empty (deleted/parse-
|
|
128
|
+
// error), server still exits 78 — that's a config anomaly, not a
|
|
129
|
+
// fresh install.
|
|
130
|
+
auth: z
|
|
131
|
+
.object({
|
|
132
|
+
first_run_bootstrap: z.boolean().default(true),
|
|
133
|
+
})
|
|
134
|
+
.default({ first_run_bootstrap: true }),
|
|
24
135
|
});
|
|
25
136
|
let _config = null;
|
|
26
137
|
function parseArgs() {
|
|
@@ -31,7 +142,14 @@ function parseArgs() {
|
|
|
31
142
|
return null;
|
|
32
143
|
}
|
|
33
144
|
export function loadConfig() {
|
|
34
|
-
|
|
145
|
+
return loadConfigFromPath(parseArgs());
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Load + validate config from an explicit path (or defaults if null).
|
|
149
|
+
* Lets non-CLI callers (daemon parent) load the same effective config
|
|
150
|
+
* the server would, without needing to mutate process.argv.
|
|
151
|
+
*/
|
|
152
|
+
export function loadConfigFromPath(configPath) {
|
|
35
153
|
let raw = {};
|
|
36
154
|
if (configPath) {
|
|
37
155
|
try {
|
package/lib/daemon.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
6
|
+
import { loadConfigFromPath } from "./config.js";
|
|
7
|
+
import { runStartupChecks, STARTUP_CHECKED_ENV } from "./startup-checks.js";
|
|
5
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
9
|
// ---------------------------------------------------------------------------
|
|
7
10
|
// Constants
|
|
8
11
|
// ---------------------------------------------------------------------------
|
|
9
12
|
const PID_FILE = "webagent.pid";
|
|
10
13
|
const LOG_FILE = "webagent.log";
|
|
14
|
+
const LOG_MAX_LINES = 5_000;
|
|
11
15
|
const RESTART_DELAY_INITIAL = 1_000;
|
|
12
16
|
const RESTART_DELAY_MAX = 30_000;
|
|
13
17
|
const STABLE_THRESHOLD_MS = 60_000;
|
|
@@ -29,13 +33,15 @@ export function readPidInfo(filePath) {
|
|
|
29
33
|
try {
|
|
30
34
|
unlinkSync(filePath);
|
|
31
35
|
}
|
|
32
|
-
catch {
|
|
36
|
+
catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
33
39
|
return null;
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
/** Write PID info to `filePath`. */
|
|
37
43
|
export function writePidInfo(filePath, info) {
|
|
38
|
-
|
|
44
|
+
atomicWriteFileSync(filePath, JSON.stringify(info) + "\n");
|
|
39
45
|
}
|
|
40
46
|
// ---------------------------------------------------------------------------
|
|
41
47
|
// Arg helpers
|
|
@@ -47,35 +53,116 @@ export function isSubcommand(arg) {
|
|
|
47
53
|
export function resolveArgs(args) {
|
|
48
54
|
const result = [...args];
|
|
49
55
|
for (let i = 0; i < result.length; i++) {
|
|
50
|
-
if (result[i] === "--config" &&
|
|
56
|
+
if (result[i] === "--config" &&
|
|
57
|
+
i + 1 < result.length &&
|
|
58
|
+
!isAbsolute(result[i + 1])) {
|
|
51
59
|
result[i + 1] = resolve(result[i + 1]);
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
return result;
|
|
55
63
|
}
|
|
56
64
|
// ---------------------------------------------------------------------------
|
|
65
|
+
// Config-path extraction for parent-side checks
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Pull the `--config <path>` value out of the daemon's argv (resolving
|
|
69
|
+
* relative paths against cwd). Used by `cmdStart` to load the same
|
|
70
|
+
* config the server child would, so the parent process can run the
|
|
71
|
+
* unified startup checks (preflight + auth bootstrap) in the operator's
|
|
72
|
+
* TTY before forking. Returns null if no `--config` was passed.
|
|
73
|
+
*/
|
|
74
|
+
export function extractConfigPath(args, cwd) {
|
|
75
|
+
const idx = args.indexOf("--config");
|
|
76
|
+
if (idx < 0 || idx + 1 >= args.length)
|
|
77
|
+
return null;
|
|
78
|
+
const v = args[idx + 1];
|
|
79
|
+
return isAbsolute(v) ? v : resolve(cwd, v);
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Restart decision (pure)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
/**
|
|
85
|
+
* Sysexits.h EX_CONFIG. Server exits 78 when configuration is bad
|
|
86
|
+
* (missing auth.json + non-TTY, preflight failures, etc.). Restarting
|
|
87
|
+
* cannot fix configuration — supervisor must surface and stop.
|
|
88
|
+
*/
|
|
89
|
+
const EX_CONFIG = 78;
|
|
90
|
+
/**
|
|
91
|
+
* Pure decision: should the supervisor restart the child, and with what
|
|
92
|
+
* delay? Side effects (logging, scheduling) live in `runSupervisor`.
|
|
93
|
+
*/
|
|
94
|
+
export function decideRestart(code, _signal, ctx) {
|
|
95
|
+
if (ctx.stopping) {
|
|
96
|
+
return { kind: "stop", reason: "supervisor shutting down" };
|
|
97
|
+
}
|
|
98
|
+
if (code === EX_CONFIG) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "stop",
|
|
101
|
+
reason: `child exited with EX_CONFIG (${EX_CONFIG}) — not restarting`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const stable = ctx.now - ctx.lastStart > STABLE_THRESHOLD_MS;
|
|
105
|
+
const delay = stable
|
|
106
|
+
? RESTART_DELAY_INITIAL
|
|
107
|
+
: Math.min(ctx.currentDelay * 2, RESTART_DELAY_MAX);
|
|
108
|
+
return { kind: "restart", delayMs: delay };
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// PID file location
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
//
|
|
114
|
+
// PID file lives in `data_dir`, NOT cwd. This lets multiple instances
|
|
115
|
+
// (e.g. one per agent / port) coexist on the same machine launched from
|
|
116
|
+
// the same shell — each `--config` points at its own data_dir, so each
|
|
117
|
+
// gets its own pid file. The log file stays in cwd (operator-facing).
|
|
118
|
+
function loadDaemonContext(args, cwd) {
|
|
119
|
+
const cfgPath = extractConfigPath(args, cwd);
|
|
120
|
+
const config = loadConfigFromPath(cfgPath);
|
|
121
|
+
const dataDir = isAbsolute(config.data_dir)
|
|
122
|
+
? config.data_dir
|
|
123
|
+
: resolve(cwd, config.data_dir);
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(dataDir, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* best-effort — start will surface real errors via startup checks */
|
|
129
|
+
}
|
|
130
|
+
return { config, pidFile: join(dataDir, PID_FILE) };
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
57
133
|
// Command dispatch
|
|
58
134
|
// ---------------------------------------------------------------------------
|
|
59
135
|
export async function run(command, args) {
|
|
60
|
-
const pidFile =
|
|
136
|
+
const { config, pidFile } = loadDaemonContext(args, process.cwd());
|
|
61
137
|
const logFile = join(process.cwd(), LOG_FILE);
|
|
62
138
|
switch (command) {
|
|
63
|
-
case "start":
|
|
64
|
-
|
|
65
|
-
case "
|
|
66
|
-
|
|
139
|
+
case "start":
|
|
140
|
+
return cmdStart(pidFile, logFile, args, config);
|
|
141
|
+
case "stop":
|
|
142
|
+
return cmdStop(pidFile);
|
|
143
|
+
case "status":
|
|
144
|
+
return cmdStatus(pidFile, logFile);
|
|
145
|
+
case "restart":
|
|
146
|
+
return cmdRestart(pidFile, logFile);
|
|
67
147
|
}
|
|
68
148
|
}
|
|
69
149
|
// ---------------------------------------------------------------------------
|
|
70
150
|
// Commands
|
|
71
151
|
// ---------------------------------------------------------------------------
|
|
72
|
-
async function cmdStart(pidFile, logFile, args) {
|
|
152
|
+
async function cmdStart(pidFile, logFile, args, config) {
|
|
73
153
|
const existing = readPidInfo(pidFile);
|
|
74
154
|
if (existing) {
|
|
75
155
|
console.log(`webagent is already running (pid ${existing.pid})`);
|
|
76
156
|
process.exitCode = 1;
|
|
77
157
|
return;
|
|
78
158
|
}
|
|
159
|
+
// Run the unified startup gate in this (foreground) process before
|
|
160
|
+
// forking. This is the operator's TTY, so first-run mint banners
|
|
161
|
+
// and `[check]` failures land where they can actually be seen and
|
|
162
|
+
// copy-pasted, instead of being entombed in the daemon log file.
|
|
163
|
+
// Pass WEBAGENT_STARTUP_CHECKED=1 to the supervisor child so it (and
|
|
164
|
+
// the server it spawns) skip re-running the gate.
|
|
165
|
+
await runStartupChecks(config);
|
|
79
166
|
const serverJs = join(__dirname, "server.js");
|
|
80
167
|
if (!existsSync(serverJs)) {
|
|
81
168
|
console.error(`server not found: ${serverJs}`);
|
|
@@ -85,8 +172,26 @@ async function cmdStart(pidFile, logFile, args) {
|
|
|
85
172
|
}
|
|
86
173
|
const resolved = resolveArgs(args);
|
|
87
174
|
const daemonJs = join(__dirname, "daemon.js");
|
|
175
|
+
// Truncate log to last LOG_MAX_LINES before starting
|
|
176
|
+
if (existsSync(logFile)) {
|
|
177
|
+
try {
|
|
178
|
+
const lines = readFileSync(logFile, "utf-8").split("\n");
|
|
179
|
+
if (lines.length > LOG_MAX_LINES) {
|
|
180
|
+
writeFileSync(logFile, lines.slice(-LOG_MAX_LINES).join("\n"));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* best-effort */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
88
187
|
const log = openSync(logFile, "a");
|
|
89
|
-
const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], {
|
|
188
|
+
const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], {
|
|
189
|
+
detached: true,
|
|
190
|
+
stdio: ["ignore", log, log],
|
|
191
|
+
cwd: process.cwd(),
|
|
192
|
+
windowsHide: true,
|
|
193
|
+
env: { ...process.env, [STARTUP_CHECKED_ENV]: "1" },
|
|
194
|
+
});
|
|
90
195
|
child.unref();
|
|
91
196
|
closeSync(log);
|
|
92
197
|
// Poll for PID file (supervisor writes it on startup)
|
|
@@ -117,7 +222,9 @@ async function cmdStop(pidFile) {
|
|
|
117
222
|
try {
|
|
118
223
|
unlinkSync(pidFile);
|
|
119
224
|
}
|
|
120
|
-
catch {
|
|
225
|
+
catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
121
228
|
return;
|
|
122
229
|
}
|
|
123
230
|
// Wait for exit
|
|
@@ -132,7 +239,9 @@ async function cmdStop(pidFile) {
|
|
|
132
239
|
try {
|
|
133
240
|
unlinkSync(pidFile);
|
|
134
241
|
}
|
|
135
|
-
catch {
|
|
242
|
+
catch {
|
|
243
|
+
/* ignore */
|
|
244
|
+
}
|
|
136
245
|
console.log("webagent stopped");
|
|
137
246
|
return;
|
|
138
247
|
}
|
|
@@ -166,7 +275,8 @@ async function cmdRestart(pidFile, logFile) {
|
|
|
166
275
|
if (process.platform === "win32") {
|
|
167
276
|
// No SIGHUP on Windows — fall back to stop + start (non-atomic)
|
|
168
277
|
await cmdStop(pidFile);
|
|
169
|
-
|
|
278
|
+
const { config } = loadDaemonContext(info.args, process.cwd());
|
|
279
|
+
await cmdStart(pidFile, logFile, info.args, config);
|
|
170
280
|
return;
|
|
171
281
|
}
|
|
172
282
|
// Unix: atomic restart via SIGHUP to supervisor
|
|
@@ -195,8 +305,12 @@ async function cmdRestart(pidFile, logFile) {
|
|
|
195
305
|
// ---------------------------------------------------------------------------
|
|
196
306
|
function runSupervisor(serverArgs) {
|
|
197
307
|
const serverJs = join(__dirname, "server.js");
|
|
198
|
-
const pidFile =
|
|
199
|
-
writePidInfo(pidFile, {
|
|
308
|
+
const { pidFile } = loadDaemonContext(serverArgs, process.cwd());
|
|
309
|
+
writePidInfo(pidFile, {
|
|
310
|
+
pid: process.pid,
|
|
311
|
+
args: serverArgs,
|
|
312
|
+
started: new Date().toISOString(),
|
|
313
|
+
});
|
|
200
314
|
let child = null;
|
|
201
315
|
let stopping = false;
|
|
202
316
|
let lastStart = 0;
|
|
@@ -204,19 +318,33 @@ function runSupervisor(serverArgs) {
|
|
|
204
318
|
let timer = null;
|
|
205
319
|
function spawnServer() {
|
|
206
320
|
lastStart = Date.now();
|
|
207
|
-
child = spawn(process.execPath, [serverJs, ...serverArgs], {
|
|
321
|
+
child = spawn(process.execPath, [serverJs, ...serverArgs], {
|
|
322
|
+
stdio: "inherit",
|
|
323
|
+
windowsHide: true,
|
|
324
|
+
});
|
|
208
325
|
child.on("exit", onChildExit);
|
|
209
326
|
}
|
|
210
327
|
function onChildExit(code, signal) {
|
|
211
328
|
child = null;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
329
|
+
const action = decideRestart(code, signal, {
|
|
330
|
+
stopping,
|
|
331
|
+
lastStart,
|
|
332
|
+
now: Date.now(),
|
|
333
|
+
currentDelay: delay,
|
|
334
|
+
});
|
|
335
|
+
if (action.kind === "stop") {
|
|
336
|
+
if (stopping)
|
|
337
|
+
return;
|
|
338
|
+
console.error(`[supervisor] ${action.reason} (code=${code} signal=${signal})`);
|
|
339
|
+
try {
|
|
340
|
+
unlinkSync(pidFile);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* ignore */
|
|
344
|
+
}
|
|
345
|
+
process.exit(code ?? 1);
|
|
219
346
|
}
|
|
347
|
+
delay = action.delayMs;
|
|
220
348
|
console.log(`[supervisor] server exited (code=${code} signal=${signal}), restarting in ${delay}ms`);
|
|
221
349
|
timer = setTimeout(spawnServer, delay);
|
|
222
350
|
}
|
|
@@ -225,18 +353,27 @@ function runSupervisor(serverArgs) {
|
|
|
225
353
|
clearTimeout(timer);
|
|
226
354
|
timer = null;
|
|
227
355
|
}
|
|
228
|
-
return new Promise((
|
|
356
|
+
return new Promise((innerResolve) => {
|
|
229
357
|
if (!child) {
|
|
230
|
-
|
|
358
|
+
innerResolve();
|
|
231
359
|
return;
|
|
232
360
|
}
|
|
233
361
|
const c = child;
|
|
234
|
-
|
|
362
|
+
// Take ownership of this exit — don't let the auto-restart listener
|
|
363
|
+
// race with the explicit respawn (SIGHUP path) or shutdown.
|
|
364
|
+
c.removeListener("exit", onChildExit);
|
|
365
|
+
c.once("exit", () => {
|
|
366
|
+
innerResolve();
|
|
367
|
+
});
|
|
235
368
|
c.kill("SIGTERM");
|
|
236
|
-
setTimeout(() => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
369
|
+
setTimeout(() => {
|
|
370
|
+
try {
|
|
371
|
+
c.kill("SIGKILL");
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
/* ignore */
|
|
375
|
+
}
|
|
376
|
+
}, KILL_GRACE_MS);
|
|
240
377
|
});
|
|
241
378
|
}
|
|
242
379
|
async function shutdown() {
|
|
@@ -247,18 +384,26 @@ function runSupervisor(serverArgs) {
|
|
|
247
384
|
try {
|
|
248
385
|
unlinkSync(pidFile);
|
|
249
386
|
}
|
|
250
|
-
catch {
|
|
387
|
+
catch {
|
|
388
|
+
/* ignore */
|
|
389
|
+
}
|
|
251
390
|
process.exit(0);
|
|
252
391
|
}
|
|
253
|
-
process.on("SIGTERM", () => {
|
|
254
|
-
|
|
392
|
+
process.on("SIGTERM", () => {
|
|
393
|
+
void shutdown();
|
|
394
|
+
});
|
|
395
|
+
process.on("SIGINT", () => {
|
|
396
|
+
void shutdown();
|
|
397
|
+
});
|
|
255
398
|
if (process.platform !== "win32") {
|
|
256
|
-
process.on("SIGHUP",
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
399
|
+
process.on("SIGHUP", () => {
|
|
400
|
+
void (async () => {
|
|
401
|
+
console.log("[supervisor] SIGHUP received, restarting server");
|
|
402
|
+
delay = RESTART_DELAY_INITIAL;
|
|
403
|
+
await killChild();
|
|
404
|
+
if (!stopping)
|
|
405
|
+
spawnServer();
|
|
406
|
+
})();
|
|
262
407
|
});
|
|
263
408
|
}
|
|
264
409
|
console.log(`[supervisor] started (pid ${process.pid})`);
|