fix: make web probe observer stop fail-safe
This commit is contained in:
@@ -27,6 +27,10 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web
|
||||
- `web-probe` 内部可以使用浏览器引擎,但认证、目标解析、截图、脱敏、artifact 和报告必须由 `web-probe` 合同统一管理;
|
||||
- 缺少所需动作时,先在 owning YAML 和 `web-probe` typed command 中补齐通用能力,不得绕过为第二操作面。
|
||||
- Web-probe 正式 CLI 入口是 `bun scripts/cli.ts web-probe ...`;旧 `hwlab nodes web-probe` 已移除,只能按 CLI 提示迁移,不要在 issue 或长期文档中继续记录旧入口。
|
||||
- `web-probe observe stop` 是 observer 生命周期清理入口:
|
||||
- 不得依赖业务页面 readiness 才执行停止;
|
||||
- graceful command 失败、超时、未消费或缺少有效结果时,默认自动进入 force-stop;
|
||||
- 输出中的 `cleanup.state=confirmed` 才证明进程已停止,`cleanup.state=unknown` 表示远端传输或清理证据仍不完整。
|
||||
- 涉及 Web 哨兵、`web-probe sentinel`、`monitor.pikapython.com`、定期/周期巡检或新建巡检时,必须同时加载 `$unidesk-monitor`。
|
||||
- 真实用户入口验证优先;源码检查、构建通过或截图局部正常不能替代原入口验收。
|
||||
- WebProbe 因内存、浏览器资源或受控启动策略阻断时:
|
||||
|
||||
@@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes";
|
||||
import { rootPath } from "../config";
|
||||
import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson } from "./web-probe-observe-actions";
|
||||
import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson, webObserveStopFallbackReason } from "./web-probe-observe-actions";
|
||||
|
||||
test("realtime authority runner contract derives topics, groups, and transactional capabilities from owning YAML", () => {
|
||||
const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01"));
|
||||
@@ -120,6 +120,34 @@ test("web observe command visibility does not equate control completion with asy
|
||||
assert.equal(visibility.asyncTurn.terminalStatusSource, "not-evaluated-by-control-command");
|
||||
});
|
||||
|
||||
test("observe stop automatically falls back when graceful cleanup is not proven", () => {
|
||||
assert.equal(webObserveStopFallbackReason({
|
||||
stopCommand: true,
|
||||
exitCode: 255,
|
||||
commandResult: null,
|
||||
}), "graceful-stop-failed");
|
||||
assert.equal(webObserveStopFallbackReason({
|
||||
stopCommand: true,
|
||||
exitCode: 0,
|
||||
commandResult: null,
|
||||
}), "graceful-stop-contract-missing");
|
||||
assert.equal(webObserveStopFallbackReason({
|
||||
stopCommand: true,
|
||||
exitCode: 0,
|
||||
commandResult: { ok: true, queued: true, waitTimedOut: true },
|
||||
}), "graceful-stop-not-consumed");
|
||||
assert.equal(webObserveStopFallbackReason({
|
||||
stopCommand: true,
|
||||
exitCode: 0,
|
||||
commandResult: { ok: true, queued: false, status: "done" },
|
||||
}), null);
|
||||
assert.equal(webObserveStopFallbackReason({
|
||||
stopCommand: false,
|
||||
exitCode: 255,
|
||||
commandResult: null,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("web observe action JSON recovery accepts concrete start contract", () => {
|
||||
const resolved = resolveWebObserveActionJson({
|
||||
stdout: JSON.stringify({
|
||||
|
||||
@@ -997,6 +997,20 @@ export function buildWebObserveCommandVisibility(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function webObserveStopFallbackReason(input: {
|
||||
stopCommand: boolean;
|
||||
exitCode: number;
|
||||
commandResult: Record<string, unknown> | null;
|
||||
}): string | null {
|
||||
if (!input.stopCommand) return null;
|
||||
if (input.exitCode !== 0) return "graceful-stop-failed";
|
||||
if (input.commandResult === null) return "graceful-stop-contract-missing";
|
||||
if (input.commandResult.ok === false || input.commandResult.status === "failed") return "graceful-stop-rejected";
|
||||
if (input.commandResult.waitTimedOut === true) return "graceful-stop-not-consumed";
|
||||
if (input.commandResult.queued === true) return "graceful-stop-queued";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record<string, unknown> | RenderedCliResult {
|
||||
const type = options.commandType ?? (stopCommand ? "stop" : null);
|
||||
if (type === null) throw new Error("web-probe observe command requires --type");
|
||||
@@ -1080,7 +1094,7 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
|
||||
return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, forceBeforeQueueReason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, null);
|
||||
}
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
const waitMs = options.force && stopCommand ? Math.max(options.waitMs, 5000) : options.waitMs;
|
||||
const waitMs = stopCommand ? Math.max(options.waitMs, 5000) : options.waitMs;
|
||||
const script = [
|
||||
"set -eu",
|
||||
nodeWebObserveResolveStateDirShell(options),
|
||||
@@ -1091,21 +1105,18 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const commandResolution = resolveWebObserveActionJson(result, "command");
|
||||
const commandResult = commandResolution.parsed;
|
||||
if (options.force && stopCommand && (result.exitCode !== 0 || commandResult?.waitTimedOut === true || commandResult?.queued === true)) {
|
||||
const reason = result.exitCode !== 0
|
||||
? "graceful-stop-failed"
|
||||
: commandResult?.waitTimedOut === true
|
||||
? "graceful-stop-not-consumed"
|
||||
: "graceful-stop-queued";
|
||||
return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, reason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, result);
|
||||
const stopFallbackReason = webObserveStopFallbackReason({ stopCommand, exitCode: result.exitCode, commandResult });
|
||||
if (stopFallbackReason !== null) {
|
||||
return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, stopFallbackReason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, result);
|
||||
}
|
||||
const visibility = buildWebObserveCommandVisibility({
|
||||
commandType: type,
|
||||
commandResult,
|
||||
exitCode: result.exitCode,
|
||||
});
|
||||
const commandOk = result.exitCode === 0 && commandResult !== null && commandResult.ok !== false;
|
||||
const payloadResult = {
|
||||
ok: result.exitCode === 0 && commandResult !== null && commandResult.ok !== false,
|
||||
ok: commandOk,
|
||||
status: visibility.status,
|
||||
command: webObserveCommandLabel(stopCommand ? "stop" : "command", options),
|
||||
id: webObserveIdFromOptions(options),
|
||||
@@ -1117,6 +1128,13 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
|
||||
observer: commandResult,
|
||||
control: visibility.control,
|
||||
asyncTurn: visibility.asyncTurn,
|
||||
cleanup: stopCommand ? {
|
||||
attempted: true,
|
||||
mode: "graceful-command",
|
||||
state: commandOk ? "requested" : "unknown",
|
||||
terminationConfirmed: false,
|
||||
valuesRedacted: true,
|
||||
} : null,
|
||||
wrapper: buildWebObserveWrapperForObserveOptions(stopCommand ? "stop" : "command", options, spec.workspace, { commandType: type }),
|
||||
stdoutJson: commandResolution.diagnostics,
|
||||
result: compactCommandResult(result),
|
||||
@@ -1143,9 +1161,13 @@ export function runNodeWebProbeObserveForceStop(
|
||||
].join("\n"), 55);
|
||||
const forceResolution = resolveWebObserveActionJson(killResult, "force-stop");
|
||||
const forcePayload = forceResolution.parsed;
|
||||
const cleanupConfirmed = killResult.exitCode === 0
|
||||
&& forcePayload !== null
|
||||
&& forcePayload.ok !== false
|
||||
&& forcePayload.aliveAfter !== true;
|
||||
const payloadResult = {
|
||||
ok: killResult.exitCode === 0 && forcePayload !== null && forcePayload.ok !== false,
|
||||
status: killResult.exitCode === 0 && forcePayload !== null && forcePayload.ok !== false ? "forced-stopped" : "blocked",
|
||||
ok: cleanupConfirmed,
|
||||
status: cleanupConfirmed ? "forced-stopped" : "blocked",
|
||||
command: webObserveCommandLabel("stop", options),
|
||||
id: webObserveIdFromOptions(options),
|
||||
node: options.node,
|
||||
@@ -1156,6 +1178,16 @@ export function runNodeWebProbeObserveForceStop(
|
||||
observer: forcePayload,
|
||||
wrapper: buildWebObserveWrapperForObserveOptions("stop", options, spec.workspace, { commandType: "stop" }),
|
||||
forceReason: reason,
|
||||
cleanup: {
|
||||
attempted: true,
|
||||
mode: "force-stop",
|
||||
state: cleanupConfirmed ? "confirmed" : "unknown",
|
||||
terminationConfirmed: cleanupConfirmed,
|
||||
processAliveAfter: forcePayload?.aliveAfter ?? null,
|
||||
transportExitCode: killResult.exitCode,
|
||||
reason,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
preflightObserver: preflightStatus,
|
||||
preflightResult: preflightResult === null ? null : compactCommandResult(preflightResult),
|
||||
gracefulResult: gracefulResult === null ? null : compactCommandResult(gracefulResult),
|
||||
|
||||
Reference in New Issue
Block a user