diff --git a/AGENTS.md b/AGENTS.md index 1f85f724..be5d44e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,4 +134,5 @@ - OTel/可观测性: `docs/reference/observability.md`。 - YAML-first: `docs/reference/yaml-first-ops.md`。 - Platform infrastructure: `docs/reference/platform-infra.md`。 +- Webterm 配置与受控部署: `docs/reference/webterm.md`。 - Secretary: `docs/reference/secretary-reference.md`。 diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index b26ad2fd..09e51389 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -29,6 +29,15 @@ autoResumePrompt: quietPeriodMs: 1500 timeoutMs: 60000 +browserTerminalHistory: + desktopScrollbackLines: 8000 + mobileScrollbackLines: 500 + mobileMaxWidthPx: 899 + +browserOutputPolicy: + maxFramesPerSecond: 10 + maxFrameBytes: 65536 + defaults: targetId: local-7683 @@ -86,8 +95,6 @@ targets: BROWSER_SNAPSHOT_WRITE_CHUNK_BYTES: "8192" BROWSER_MANUAL_REDRAW_MODE: double BROWSER_MANUAL_REDRAW_DELAY_MS: "60" - OUTPUT_BATCH_MS: "8" - OUTPUT_BATCH_BYTES: "32768" BROWSER_KEEPALIVE_ENABLED: "true" BROWSER_RESUME_PROBE_ENABLED: "false" BROWSER_KEEPALIVE_INTERVAL_MS: "30000" @@ -167,8 +174,6 @@ targets: BROWSER_SNAPSHOT_WRITE_CHUNK_BYTES: "8192" BROWSER_MANUAL_REDRAW_MODE: double BROWSER_MANUAL_REDRAW_DELAY_MS: "60" - OUTPUT_BATCH_MS: "8" - OUTPUT_BATCH_BYTES: "32768" BROWSER_KEEPALIVE_ENABLED: "true" BROWSER_RESUME_PROBE_ENABLED: "false" BROWSER_KEEPALIVE_INTERVAL_MS: "15000" diff --git a/docs/reference/webterm.md b/docs/reference/webterm.md new file mode 100644 index 00000000..1ea24179 --- /dev/null +++ b/docs/reference/webterm.md @@ -0,0 +1,18 @@ +# Webterm 配置与受控部署 + +- 唯一配置来源: + - `config/platform-infra/webterm.yaml` 是端口、自动标签、启动模式、浏览器历史和输出聚合策略的 owning YAML; + - `build/docker-compose..yaml` 是受控 CLI 渲染产物,禁止手工维护或在部署后恢复旧内容。 +- 受控入口: + - 预览使用 `bun scripts/cli.ts platform-infra webterm plan --target `; + - 部署使用 `bun scripts/cli.ts platform-infra webterm apply --target --confirm`; + - 收口使用同一入口的 `status` 与 `validate`。 +- 浏览器历史策略: + - `browserTerminalHistory.desktopScrollbackLines` 控制桌面端 xterm 历史行数; + - `browserTerminalHistory.mobileScrollbackLines` 控制移动端 xterm 历史行数; + - `browserTerminalHistory.mobileMaxWidthPx` 与粗指针特征共同决定移动端策略; + - 视口类型变化后,已有终端必须同步新上限并截断超出上限的旧行。 +- 输出聚合策略: + - `browserOutputPolicy.maxFramesPerSecond` 是每个浏览器连接、每个终端的最大输出消息频率; + - `browserOutputPolicy.maxFrameBytes` 是单条聚合输出消息的字节上限; + - 后端必须按终端独立排队,小数据在频率窗口内合并,积压数据按窗口顺序发送,禁止用字节阈值绕过频率上限。 diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 76bf1a8c..48c2110f 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -23,6 +23,8 @@ interface WebtermConfig { defaults: { targetId: string }; sessionStartModes: SessionStartModes; autoResumePrompt: AutoResumePrompt; + browserTerminalHistory: BrowserTerminalHistory; + browserOutputPolicy: BrowserOutputPolicy; targets: WebtermTarget[]; } @@ -39,6 +41,17 @@ interface AutoResumePrompt { readiness: { minDelayMs: number; minOutputBytes: number; quietPeriodMs: number; timeoutMs: number }; } +interface BrowserTerminalHistory { + desktopScrollbackLines: number; + mobileScrollbackLines: number; + mobileMaxWidthPx: number; +} + +interface BrowserOutputPolicy { + maxFramesPerSecond: number; + maxFrameBytes: number; +} + interface WebtermTarget { id: string; enabled: boolean; @@ -59,6 +72,8 @@ interface WebtermTarget { autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean; fallbackShell: boolean }>; sessionStartModes: SessionStartModes; autoResumePrompt: AutoResumePrompt; + browserTerminalHistory: BrowserTerminalHistory; + browserOutputPolicy: BrowserOutputPolicy; environment: Record; }; publicExposure: { @@ -256,17 +271,35 @@ function readWebtermConfig(): WebtermConfig { const defaults = recordField(yaml, "defaults", "webterm"); const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes"); const autoResumePrompt = parseAutoResumePrompt(recordField(yaml, "autoResumePrompt", "webterm"), "webterm.autoResumePrompt"); + const browserTerminalHistory = parseBrowserTerminalHistory(recordField(yaml, "browserTerminalHistory", "webterm"), "webterm.browserTerminalHistory"); + const browserOutputPolicy = parseBrowserOutputPolicy(recordField(yaml, "browserOutputPolicy", "webterm"), "webterm.browserOutputPolicy"); const targetsRaw = yaml.targets; if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); return { defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, sessionStartModes, autoResumePrompt, - targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt)), + browserTerminalHistory, + browserOutputPolicy, + targets: targetsRaw.map((item, index) => parseTarget( + asRecord(item, `webterm.targets[${index}]`), + `webterm.targets[${index}]`, + sessionStartModes, + autoResumePrompt, + browserTerminalHistory, + browserOutputPolicy, + )), }; } -function parseTarget(record: Record, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt): WebtermTarget { +function parseTarget( + record: Record, + path: string, + sessionStartModes: SessionStartModes, + autoResumePrompt: AutoResumePrompt, + browserTerminalHistory: BrowserTerminalHistory, + browserOutputPolicy: BrowserOutputPolicy, +): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); const dns = recordField(exposure, "dns", `${path}.publicExposure`); @@ -295,6 +328,8 @@ function parseTarget(record: Record, path: string, sessionStart autoStartSessions, sessionStartModes, autoResumePrompt, + browserTerminalHistory, + browserOutputPolicy, environment, }, publicExposure: { @@ -339,6 +374,8 @@ function renderCompose(target: WebtermTarget): string { SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options), SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode, AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt), + BROWSER_TERMINAL_HISTORY_JSON: JSON.stringify(target.runtime.browserTerminalHistory), + BROWSER_OUTPUT_POLICY_JSON: JSON.stringify(target.runtime.browserOutputPolicy), }; const envLines = Object.entries(environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) @@ -442,6 +479,8 @@ function targetSummary(target: WebtermTarget): Record { sendEnter: target.runtime.autoResumePrompt.sendEnter, enterDelayMs: target.runtime.autoResumePrompt.enterDelayMs, }, + browserTerminalHistory: target.runtime.browserTerminalHistory, + browserOutputPolicy: target.runtime.browserOutputPolicy, sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, @@ -601,6 +640,27 @@ function parseAutoResumePrompt(record: Record, path: string): A }; } +function parseBrowserTerminalHistory(record: Record, path: string): BrowserTerminalHistory { + return { + desktopScrollbackLines: positiveIntegerField(record, "desktopScrollbackLines", path), + mobileScrollbackLines: positiveIntegerField(record, "mobileScrollbackLines", path), + mobileMaxWidthPx: positiveIntegerField(record, "mobileMaxWidthPx", path), + }; +} + +function parseBrowserOutputPolicy(record: Record, path: string): BrowserOutputPolicy { + return { + maxFramesPerSecond: positiveIntegerField(record, "maxFramesPerSecond", path), + maxFrameBytes: positiveIntegerField(record, "maxFrameBytes", path), + }; +} + +function positiveIntegerField(record: Record, key: string, path: string): number { + const value = integerField(record, key, path); + if (value <= 0) throw new Error(`${path}.${key} must be positive`); + return value; +} + function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected;