// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-18-p19-external-form-workflow.
// Responsibility: Typed lifecycle and Secret injection for declared external form workflows.
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename, isAbsolute, resolve } from "node:path";

import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
import { runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell } from "./hwlab-node-web-probe-memory-guard";
import { readDeclaredSecretValues } from "./secrets";
import { nodeWebProbeHostProxyEnv } from "./hwlab-node/web-probe-observe";
import { runTransWorkspaceStdinScript } from "./hwlab-node/public-exposure";
import { shellQuote } from "./hwlab-node/utils";
import { webProbeExternalFormRunnerSource } from "./web-probe-external-form-runner-source";

type ExternalFormAction = {
  readonly id: string;
  readonly type: "goto" | "fill" | "typeText" | "select" | "check" | "upload" | "click" | "submit" | "press" | "waitFor" | "switchPage" | "screenshot" | "inspect" | "pause";
  readonly selector?: string;
  readonly url?: string;
  readonly value?: string;
  readonly valueRef?: string;
  readonly label?: string;
  readonly checked?: boolean;
  readonly path?: string;
  readonly reason?: string;
  readonly key?: "Enter" | "ArrowDown" | "ArrowUp" | "Tab" | "Escape";
  readonly state?: string;
  readonly waitUntil?: string;
  readonly timeoutMs?: number;
};

type ExternalFormWorkflow = {
  readonly source: string;
  readonly id: string;
  readonly node: string;
  readonly lane: string;
  readonly origin: string;
  readonly allowedHosts: readonly string[];
  readonly browserProxyMode: "auto" | "direct";
  readonly fixtureHtml: string | null;
  readonly viewport: { readonly width: number; readonly height: number };
  readonly timeoutMs: number;
  readonly maxUploadBytes: number;
  readonly commandTimeoutSeconds: number;
  readonly pollMs: number;
  readonly remoteRoot: string;
  readonly allowIrreversibleSubmit: boolean;
  readonly secret: {
    readonly configPath: string;
    readonly targetId: string;
    readonly secretName: string;
    readonly targetKeys: readonly string[];
  };
  readonly actions: readonly ExternalFormAction[];
};

type ExternalFormOptions = {
  readonly action: "start" | "status" | "continue" | "stop" | "plan";
  readonly workflowPath: string;
  readonly workflowId: string | null;
  readonly confirmSubmit: boolean;
  readonly continuationValue: string | null;
};

export function webProbeExternalFormHelp(): Record<string, unknown> {
  return {
    ok: true,
    command: "web-probe external-form",
    description: "执行 YAML/JSON 声明的受控外部表单，并在验证码处保持浏览器会话等待人工续填。",
    usage: [
      "bun scripts/cli.ts web-probe external-form plan --workflow <file>",
      "bun scripts/cli.ts web-probe external-form start --workflow <file>",
      "bun scripts/cli.ts web-probe external-form status --workflow <file> --id <workflow-id>",
      "printf '%s\\n' '<value>' | bun scripts/cli.ts web-probe external-form continue --workflow <file> --id <workflow-id> --value-stdin",
      "bun scripts/cli.ts web-probe external-form stop --workflow <file> --id <workflow-id>",
    ],
    safety: {
      secrets: "表单敏感值只允许通过工作流的 Secret sourceRef/valueRef 注入。",
      continuation: "验证码等一次性值只从 stdin 读取，不接受命令行明文参数。",
      submission: "submit 默认拒绝；工作流声明允许且 start 显式 --confirm-submit 时才执行。",
      output: "输出只披露动作 ID、presence、fingerprint、进度和 artifact 哈希。",
    },
    valuesRedacted: true,
  };
}

export async function runWebProbeExternalForm(args: string[]): Promise<Record<string, unknown>> {
  const options = await parseOptions(args);
  const workflow = readWorkflow(options.workflowPath);
  if (!isHwlabRuntimeLane(workflow.lane)) throw new Error(`外部表单 lane 未声明：${workflow.lane}`);
  const spec = hwlabRuntimeLaneSpecForNode(workflow.lane, workflow.node);
  if (options.action === "plan") return plan(workflow);
  if (options.action === "start") return start(workflow, spec, options.confirmSubmit);
  const workflowId = requiredWorkflowId(options.workflowId);
  if (options.action === "status") return remoteStatus(workflow, spec.workspace, workflowId);
  if (options.action === "continue") {
    if (options.continuationValue === null) throw new Error("web-probe external-form continue 需要 --value-stdin");
    return continueWorkflow(workflow, spec.workspace, workflowId, options.continuationValue);
  }
  return stop(workflow, spec.workspace, workflowId);
}

async function parseOptions(args: string[]): Promise<ExternalFormOptions> {
  const action = args[0];
  if (action !== "start" && action !== "status" && action !== "continue" && action !== "stop" && action !== "plan") {
    throw new Error("web-probe external-form 需要 plan|start|status|continue|stop");
  }
  const values = new Map<string, string>();
  const known = new Set(["--workflow", "--id"]);
  let valueStdin = false;
  let confirmSubmit = false;
  for (let index = 1; index < args.length; index += 1) {
    const item = args[index];
    if (item === "--value-stdin") {
      if (valueStdin) throw new Error("--value-stdin 不能重复");
      valueStdin = true;
      continue;
    }
    if (item === "--confirm-submit") {
      if (confirmSubmit) throw new Error("--confirm-submit 不能重复");
      confirmSubmit = true;
      continue;
    }
    const equals = item.indexOf("=");
    const key = equals > 0 ? item.slice(0, equals) : item;
    if (!known.has(key)) throw new Error(`web-probe external-form 不支持参数 ${item}`);
    const value = equals > 0 ? item.slice(equals + 1) : args[++index];
    if (value === undefined || value.startsWith("--")) throw new Error(`${key} 需要一个值`);
    if (values.has(key)) throw new Error(`${key} 不能重复`);
    values.set(key, value);
  }
  const workflowPath = values.get("--workflow");
  if (workflowPath === undefined) throw new Error("web-probe external-form 缺少 --workflow");
  if (valueStdin && action !== "continue") throw new Error("--value-stdin 只适用于 continue");
  if (confirmSubmit && action !== "start") throw new Error("--confirm-submit 只适用于 start");
  const continuationValue = valueStdin ? (await Bun.stdin.text()).replace(/\r?\n$/u, "") : null;
  if (valueStdin && !continuationValue) throw new Error("--value-stdin 未收到非空值");
  return {
    action,
    workflowPath,
    workflowId: values.get("--id") ?? null,
    confirmSubmit,
    continuationValue,
  };
}

function readWorkflow(pathArg: string): ExternalFormWorkflow {
  const absolute = isAbsolute(pathArg) ? pathArg : rootPath(pathArg);
  const raw = readFileSync(absolute, "utf8");
  const root = record(pathArg.endsWith(".json") ? JSON.parse(raw) : Bun.YAML.parse(raw), pathArg);
  if (text(root.apiVersion, "apiVersion") !== "unidesk.pikastech.local/v1alpha1") throw new Error(`${pathArg}.apiVersion 不受支持`);
  if (text(root.kind, "kind") !== "ExternalFormWorkflow") throw new Error(`${pathArg}.kind 必须是 ExternalFormWorkflow`);
  const metadata = record(root.metadata, `${pathArg}.metadata`);
  const spec = record(root.spec, `${pathArg}.spec`);
  const runner = record(spec.runner, `${pathArg}.spec.runner`);
  const origin = record(spec.origin, `${pathArg}.spec.origin`);
  const browser = record(spec.browser, `${pathArg}.spec.browser`);
  const state = record(spec.state, `${pathArg}.spec.state`);
  const secret = record(spec.secret, `${pathArg}.spec.secret`);
  const fixture = spec.fixture === undefined ? null : record(spec.fixture, `${pathArg}.spec.fixture`);
  const originUrl = new URL(text(origin.url, "spec.origin.url")).toString();
  const allowedHosts = textArray(origin.allowedHosts, "spec.origin.allowedHosts");
  if (!allowedHosts.includes(new URL(originUrl).hostname)) throw new Error("spec.origin.allowedHosts 必须包含 origin.url 主机");
  const actions = recordArray(spec.actions, "spec.actions").map(parseAction);
  const ids = new Set<string>();
  for (const action of actions) {
    if (ids.has(action.id)) throw new Error(`重复 action id：${action.id}`);
    ids.add(action.id);
  }
  return {
    source: absolute,
    id: simpleId(text(metadata.id, "metadata.id"), "metadata.id"),
    node: simpleId(text(runner.node, "spec.runner.node"), "spec.runner.node"),
    lane: simpleId(text(runner.lane, "spec.runner.lane"), "spec.runner.lane"),
    origin: originUrl,
    allowedHosts,
    browserProxyMode: optionalEnum(origin.browserProxyMode, ["auto", "direct"], "spec.origin.browserProxyMode") ?? "auto",
    fixtureHtml: fixture === null ? null : readFixtureHtml(text(fixture.htmlPath, "spec.fixture.htmlPath")),
    viewport: {
      width: positiveInteger(browser.viewportWidth, "spec.browser.viewportWidth"),
      height: positiveInteger(browser.viewportHeight, "spec.browser.viewportHeight"),
    },
    timeoutMs: positiveInteger(browser.timeoutMs, "spec.browser.timeoutMs"),
    maxUploadBytes: positiveInteger(browser.maxUploadBytes, "spec.browser.maxUploadBytes"),
    commandTimeoutSeconds: positiveInteger(browser.commandTimeoutSeconds, "spec.browser.commandTimeoutSeconds"),
    pollMs: positiveInteger(browser.pollMs, "spec.browser.pollMs"),
    remoteRoot: safeRemoteRoot(text(state.remoteRoot, "spec.state.remoteRoot")),
    allowIrreversibleSubmit: spec.allowIrreversibleSubmit === true,
    secret: {
      configPath: text(secret.configPath, "spec.secret.configPath"),
      targetId: text(secret.targetId, "spec.secret.targetId"),
      secretName: text(secret.secretName, "spec.secret.secretName"),
      targetKeys: textArray(secret.targetKeys, "spec.secret.targetKeys"),
    },
    actions,
  };
}

function plan(workflow: ExternalFormWorkflow): Record<string, unknown> {
  return {
    ok: true,
    command: "web-probe external-form plan",
    workflow: summary(workflow),
    lifecycle: ["start", "status", "continue", "stop"],
    irreversibleSubmit: {
      declared: workflow.allowIrreversibleSubmit,
      present: workflow.actions.some((action) => action.type === "submit"),
      requiresExplicitConfirmation: true,
    },
    secret: {
      configPath: workflow.secret.configPath,
      targetId: workflow.secret.targetId,
      secretName: workflow.secret.secretName,
      targetKeys: workflow.secret.targetKeys,
      valuesPrinted: false,
    },
    mutation: false,
    valuesRedacted: true,
  };
}

function start(workflow: ExternalFormWorkflow, spec: ReturnType<typeof hwlabRuntimeLaneSpecForNode>, confirmSubmit: boolean): Record<string, unknown> {
  const hasSubmit = workflow.actions.some((action) => action.type === "submit");
  if (hasSubmit && (!workflow.allowIrreversibleSubmit || !confirmSubmit)) {
    throw new Error("工作流包含不可逆 submit；必须同时声明 allowIrreversibleSubmit=true 并显式传 --confirm-submit");
  }
  const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start");
  if (memoryGuard.status !== "allowed") {
    return {
      ...memoryGuard,
      command: "web-probe external-form start",
      workflow: summary(workflow),
      secretResolved: false,
      mutation: false,
      valuesRedacted: true,
    };
  }
  const declared = readDeclaredSecretValues(workflow.secret);
  const workflowId = `${workflow.id}-${randomBytes(6).toString("hex")}`;
  const stateDir = `${workflow.remoteRoot}/${workflowId}`;
  const proxy = nodeWebProbeHostProxyEnv(spec, workflow.browserProxyMode);
  const prepared = prepareActions(workflow, stateDir);
  const payload = {
    workflowId,
    origin: workflow.origin,
    allowedHosts: workflow.allowedHosts,
    viewport: workflow.viewport,
    timeoutMs: workflow.timeoutMs,
    pollMs: workflow.pollMs,
    actions: prepared.actions,
    browserProxyMode: workflow.browserProxyMode,
    fixtureHtml: workflow.fixtureHtml,
  };
  const runner = webProbeExternalFormRunnerSource();
  const setup = [
    "set -eu",
    `state_dir=${shellQuote(stateDir)}`,
    'umask 077; mkdir -p "$state_dir/continuations" "$state_dir/artifacts" "$state_dir/uploads"',
    b64Write("workflow.json", JSON.stringify(payload)),
    b64Write("secrets.json", JSON.stringify(declared.values)),
    b64Write("runner.mjs", runner),
    ...prepared.uploads.map((upload) => b64WriteBuffer(`uploads/${upload.name}`, upload.bytes)),
    'chmod 600 "$state_dir/workflow.json" "$state_dir/secrets.json" "$state_dir/runner.mjs"',
  ];
  const runnerEnvironment = [
    ...proxy.envAssignments,
    ...webProbeBrowserEvidenceEnvAssignments(spec),
    ...(spec.webProbe?.playwrightBrowsersPath === undefined ? [] : [`PLAYWRIGHT_BROWSERS_PATH=${shellQuote(spec.webProbe.playwrightBrowsersPath)}`]),
    'UNIDESK_EXTERNAL_FORM_STATE_DIR="$state_dir"',
    `UNIDESK_EXTERNAL_FORM_LAUNCHER_PATH=${shellQuote(resolve(spec.workspace, "scripts/src/browser-launcher.mjs"))}`,
  ].join(" ");
  const launchPolicy = spec.webProbe?.resourcePolicy?.memoryStartGuard;
  const lifecyclePolicy = spec.webProbe?.resourcePolicy?.observerLifecycle;
  if (launchPolicy === undefined) throw new Error("web-probe memory start policy is missing from the owning YAML");
  if (lifecyclePolicy === undefined) throw new Error("web-probe observer lifecycle policy is missing from the owning YAML");
  const cleanupPollSeconds = Math.max(0.05, launchPolicy.launchReadyPollMilliseconds / 1000);
  const cleanupPollAttempts = Math.max(1, Math.ceil(lifecyclePolicy.closeStepTimeoutMs / launchPolicy.launchReadyPollMilliseconds));
  const launchFailureSource = [
    "const fs=require('fs');",
    "const crypto=require('crypto');",
    "const dir=process.argv[1], id=process.argv[2], pid=Number(process.argv[3]);",
    "const bool=(v)=>v==='true';",
    "const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}};",
    "const stderr=(()=>{try{return fs.readFileSync(dir+'/stderr.log')}catch{return Buffer.alloc(0)}})();",
    "const tail=stderr.subarray(Math.max(0,stderr.length-2048));",
    "const digest=(v)=>'sha256:'+crypto.createHash('sha256').update(v).digest('hex');",
    "const status=read('status.json');",
    "console.log(JSON.stringify({ok:false,status:'blocked',reason:status?.error?.code||'launch-readiness-unconfirmed',failureKind:'web-probe-external-form-launch-readiness',workflowId:id,pid,stateDir:dir,phase:'failed',browserCreated:false,browserCreationAttempted:true,launchReadiness:read('launch-ready.json'),statusArtifact:status,diagnostic:{stderrBytes:stderr.length,stderrHash:digest(stderr),stderrTailBytes:tail.length,stderrTailHash:digest(tail),valuesPrinted:false},cleanup:{attempted:true,processGroupId:pid,termSent:bool(process.argv[4]),killSent:bool(process.argv[5]),aliveAfter:bool(process.argv[6]),completed:!bool(process.argv[6]),valuesRedacted:true},valuesRedacted:true}))",
  ].join("");
  const launch = [
    ...setup,
    "if ! command -v setsid >/dev/null 2>&1; then rm -f \"$state_dir/secrets.json\"; rm -rf \"$state_dir/uploads\"; printf '%s\\n' '{\"ok\":false,\"failureKind\":\"host-setsid-unavailable\",\"valuesRedacted\":true}'; exit 0; fi",
    `setsid env ${runnerEnvironment} node "$state_dir/runner.mjs" >"$state_dir/stdout.log" 2>"$state_dir/stderr.log" </dev/null 9>&- &`,
    "pid=$!",
    'printf "%s\\n" "$pid" >"$state_dir/pid"',
    "set +e",
    `node - "$pid" "$state_dir" ${shellQuote(workflowId)} ${shellQuote(String(launchPolicy.launchReadyTimeoutSeconds * 1000))} ${shellQuote(String(launchPolicy.launchReadyPollMilliseconds))} >"$state_dir/launch-check.json" <<'NODE'`,
    externalFormLaunchReadinessSource(),
    "NODE",
    "launch_ready_rc=$?",
    "set -e",
    `if [ "$launch_ready_rc" -ne 0 ]; then term_sent=false; if /bin/kill -TERM -- -"$pid" 2>/dev/null; then term_sent=true; fi; i=0; while [ "$i" -lt ${cleanupPollAttempts} ] && /bin/kill -0 -- -"$pid" 2>/dev/null; do sleep ${cleanupPollSeconds}; i=$((i+1)); done; kill_sent=false; if /bin/kill -0 -- -"$pid" 2>/dev/null; then if /bin/kill -KILL -- -"$pid" 2>/dev/null; then kill_sent=true; fi; fi; i=0; while [ "$i" -lt ${cleanupPollAttempts} ] && /bin/kill -0 -- -"$pid" 2>/dev/null; do sleep ${cleanupPollSeconds}; i=$((i+1)); done; alive_after=false; if /bin/kill -0 -- -"$pid" 2>/dev/null; then alive_after=true; fi; rm -f "$state_dir/secrets.json"; rm -rf "$state_dir/uploads"; node -e ${shellQuote(launchFailureSource)} "$state_dir" ${shellQuote(workflowId)} "$pid" "$term_sent" "$kill_sent" "$alive_after"; exit 0; fi`,
    `node -e ${shellQuote("const fs=require('fs'); const dir=process.argv[1]; const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}}; const ready=read('launch-ready.json'); console.log(JSON.stringify({ok:true,jobId:process.argv[2],workflowId:process.argv[2],pid:Number(process.argv[3]),workerPid:Number(process.argv[3]),browserPid:ready&&ready.browserPid||null,phase:'running',launchReadiness:ready,valuesRedacted:true}))")} "$state_dir" ${shellQuote(workflowId)} "$pid"`,
  ].join("\n");
  const shell = webProbeGuardedLaunchShell(spec, "manual-start", launch, "structured-output");
  const result = runTransWorkspaceStdinScript(workflow.node, spec.workspace, shell, workflow.commandTimeoutSeconds);
  const parsed = parseRemoteJson(result.stdout);
  return {
    ok: result.exitCode === 0 && parsed?.ok === true,
    command: "web-probe external-form start",
    workflowId,
    node: workflow.node,
    lane: workflow.lane,
    workspace: spec.workspace,
    phase: parsed?.phase ?? "blocked",
    stateDir,
    failureKind: parsed?.failureKind ?? (result.exitCode === 0 ? null : "web-probe-external-form-start-transport"),
    launchReadiness: parsed?.launchReadiness ?? null,
    diagnostic: parsed?.diagnostic ?? null,
    cleanup: parsed?.cleanup ?? null,
    secret: declared.summary,
    transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
    next: {
      status: nextCommand("status", workflow.source, workflowId),
      continue: `${nextCommand("continue", workflow.source, workflowId)} --value-stdin`,
      stop: nextCommand("stop", workflow.source, workflowId),
    },
    valuesRedacted: true,
  };
}

function remoteStatus(workflow: ExternalFormWorkflow, workspace: string, workflowId: string): Record<string, unknown> {
  const stateDir = statePath(workflow, workflowId);
  const shell = [
    "set -eu",
    `state_dir=${shellQuote(stateDir)}`,
    'test -f "$state_dir/status.json"',
    'node - "$state_dir/status.json" "$state_dir/pid" "$state_dir" <<\'NODE\'',
    "const fs=require('fs'); const s=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));",
    "let pid=null,alive=false; try{pid=Number(fs.readFileSync(process.argv[3],'utf8').trim()); const cmd=fs.readFileSync('/proc/'+pid+'/cmdline','utf8').replace(/\\0/g,' '); alive=cmd.includes(process.argv[4]+'/runner.mjs')}catch{}",
    "const stale=!alive&&['starting','running','paused'].includes(String(s.phase));",
    "let uploadMaterialPresent=false; try{uploadMaterialPresent=fs.readdirSync(process.argv[4]+'/uploads').length>0}catch{}",
    "console.log(JSON.stringify({...s,ok:s.ok===true&&!stale&&s.phase!=='failed',processAlive:alive,secretMaterialPresent:fs.existsSync(process.argv[4]+'/secrets.json'),uploadMaterialPresent,staleStart:stale,failureKind:stale?'web-probe-external-form-stale-start':s.phase==='failed'?s.error?.code||'external-form-runner-failed':null,phase:stale?'failed':s.phase,valuesRedacted:true}));",
    "NODE",
  ].join("\n");
  const result = runTransWorkspaceStdinScript(workflow.node, workspace, shell, workflow.commandTimeoutSeconds);
  const parsed = parseRemoteJson(result.stdout);
  return {
    ok: result.exitCode === 0 && parsed?.ok === true,
    command: "web-probe external-form status",
    node: workflow.node,
    lane: workflow.lane,
    workspace,
    ...(parsed ?? { phase: "not-found" }),
    transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
    valuesRedacted: true,
  };
}

function continueWorkflow(workflow: ExternalFormWorkflow, workspace: string, workflowId: string, value: string): Record<string, unknown> {
  const stateDir = statePath(workflow, workflowId);
  const payload = Buffer.from(JSON.stringify({ value }), "utf8").toString("base64");
  const shell = [
    "set -eu",
    `state_dir=${shellQuote(stateDir)}`,
    'status_file="$state_dir/status.json"; test -f "$status_file"',
    'pause_id=$(node - "$status_file" <<\'NODE\'',
    "const fs=require('fs'); const s=JSON.parse(fs.readFileSync(process.argv[2],'utf8')); if(s.phase!=='paused'||!s.pause?.id) process.exit(2); process.stdout.write(s.pause.id);",
    "NODE",
    ")",
    'case "$pause_id" in ""|*[!A-Za-z0-9_.-]*) exit 2;; esac',
    'file="$state_dir/continuations/$pause_id.json"',
    `printf %s ${shellQuote(payload)} | base64 -d >"$file"`,
    'chmod 600 "$file"',
    'printf \'{"ok":true,"phase":"continuation-queued","valuesRedacted":true}\\n\'',
  ].join("\n");
  const result = runTransWorkspaceStdinScript(workflow.node, workspace, shell, workflow.commandTimeoutSeconds);
  const parsed = parseRemoteJson(result.stdout);
  return {
    ok: result.exitCode === 0 && parsed?.ok === true,
    command: "web-probe external-form continue",
    workflowId,
    phase: parsed?.phase ?? "blocked",
    valueAccepted: result.exitCode === 0,
    valuesPrinted: false,
    transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
    next: { status: nextCommand("status", workflow.source, workflowId) },
    valuesRedacted: true,
  };
}

function stop(workflow: ExternalFormWorkflow, workspace: string, workflowId: string): Record<string, unknown> {
  const stateDir = statePath(workflow, workflowId);
  const shell = [
    "set -eu",
    `state_dir=${shellQuote(stateDir)}`,
    'pid=$(cat "$state_dir/pid"); case "$pid" in ""|*[!0-9]*) exit 2;; esac',
    'group_alive() { ps -o stat= -g "$pid" 2>/dev/null | grep -qv "^[[:space:]]*Z"; }',
    'alive_before=false; owned=false; if [ -r "/proc/$pid/cmdline" ] && tr "\\000" " " <"/proc/$pid/cmdline" | grep -F -- "$state_dir/runner.mjs" >/dev/null; then owned=true; fi',
    'if [ "$owned" != true ]; then rm -f "$state_dir/secrets.json"; rm -rf "$state_dir/uploads"; printf \'{"ok":true,"phase":"stopped","aliveBefore":false,"aliveAfter":false,"valuesRedacted":true}\\n\'; exit 0; fi',
    'if group_alive; then alive_before=true; /bin/kill -TERM -- "-$pid" 2>/dev/null || true; fi',
    "attempt=0",
    'while group_alive && [ "$attempt" -lt 20 ]; do attempt=$((attempt + 1)); sleep 0.25; done',
    'if group_alive; then /bin/kill -KILL -- "-$pid" 2>/dev/null || true; fi',
    'alive_after=false; if group_alive; then alive_after=true; fi',
    'node - "$alive_before" "$alive_after" <<\'NODE\'',
    "console.log(JSON.stringify({ok:process.argv[3]==='false',phase:'stopped',aliveBefore:process.argv[2]==='true',aliveAfter:process.argv[3]==='true',valuesRedacted:true}));",
    "NODE",
  ].join("\n");
  const result = runTransWorkspaceStdinScript(workflow.node, workspace, shell, workflow.commandTimeoutSeconds);
  const parsed = parseRemoteJson(result.stdout);
  return {
    ok: result.exitCode === 0 && parsed?.ok === true,
    command: "web-probe external-form stop",
    workflowId,
    ...(parsed ?? { phase: "blocked" }),
    transport: { exitCode: result.exitCode, timedOut: result.timedOut, stderrHash: sha(result.stderr), valuesRedacted: true },
    valuesRedacted: true,
  };
}

function parseAction(value: Record<string, unknown>, index: number): ExternalFormAction {
  const label = `spec.actions[${index}]`;
  const type = optionalEnum(value.type, ["goto", "fill", "typeText", "select", "check", "upload", "click", "submit", "press", "waitFor", "switchPage", "screenshot", "inspect", "pause"], `${label}.type`);
  if (type === null) throw new Error(`${label}.type 缺失`);
  const action: ExternalFormAction = {
    id: simpleId(text(value.id, `${label}.id`), `${label}.id`),
    type,
    selector: optionalText(value.selector, `${label}.selector`),
    url: optionalText(value.url, `${label}.url`),
    value: optionalText(value.value, `${label}.value`),
    valueRef: optionalText(value.valueRef, `${label}.valueRef`),
    label: optionalText(value.label, `${label}.label`),
    checked: value.checked === undefined ? undefined : value.checked === true,
    path: optionalText(value.path, `${label}.path`),
    reason: optionalText(value.reason, `${label}.reason`),
    key: optionalEnum(value.key, ["Enter", "ArrowDown", "ArrowUp", "Tab", "Escape"], `${label}.key`) ?? undefined,
    state: optionalText(value.state, `${label}.state`),
    waitUntil: optionalText(value.waitUntil, `${label}.waitUntil`),
    timeoutMs: value.timeoutMs === undefined ? undefined : positiveInteger(value.timeoutMs, `${label}.timeoutMs`),
  };
  if (type === "goto" && action.url === undefined) throw new Error(`${label}.url 缺失`);
  if (["fill", "typeText"].includes(type) && action.value === undefined && action.valueRef === undefined) throw new Error(`${label} 缺少 value/valueRef`);
  if (type === "select" && action.value === undefined && action.valueRef === undefined && action.label === undefined) throw new Error(`${label} 缺少 value/valueRef/label`);
  if (["fill", "typeText", "select", "check", "upload", "click", "submit", "press", "waitFor"].includes(type) && action.selector === undefined) throw new Error(`${label}.selector 缺失`);
  if (type === "press" && action.key === undefined) throw new Error(`${label}.key 缺失`);
  if (type === "upload" && action.path === undefined) throw new Error(`${label}.path 缺失`);
  if (type === "pause" && action.reason === undefined) throw new Error(`${label}.reason 缺失`);
  return action;
}

function summary(workflow: ExternalFormWorkflow): Record<string, unknown> {
  return {
    id: workflow.id,
    source: workflow.source,
    node: workflow.node,
    lane: workflow.lane,
    origin: new URL(workflow.origin).origin,
    allowedHosts: workflow.allowedHosts,
    actionCount: workflow.actions.length,
    fixture: workflow.fixtureHtml === null ? null : { repoOwned: true, valuesPrinted: false },
    pauseIds: workflow.actions.filter((action) => action.type === "pause").map((action) => action.id),
    valuesRedacted: true,
  };
}

function b64Write(name: string, value: string): string {
  return b64WriteBuffer(name, Buffer.from(value, "utf8"));
}

function b64WriteBuffer(name: string, value: Buffer): string {
  return `printf %s ${shellQuote(value.toString("base64"))} | base64 -d >"$state_dir/${name}"`;
}

function prepareActions(workflow: ExternalFormWorkflow, stateDir: string): {
  actions: ExternalFormAction[];
  uploads: Array<{ name: string; bytes: Buffer }>;
} {
  const uploads: Array<{ name: string; bytes: Buffer }> = [];
  const actions = workflow.actions.map((action) => {
    if (action.type !== "upload" || action.path === undefined) return action;
    const bytes = readFileSync(isAbsolute(action.path) ? action.path : rootPath(action.path));
    if (bytes.byteLength === 0 || bytes.byteLength > workflow.maxUploadBytes) {
      throw new Error(`upload ${action.id} 文件必须为 1B 到 ${workflow.maxUploadBytes}B`);
    }
    const fileName = `${action.id}-${safeFileName(basename(action.path))}`;
    uploads.push({ name: fileName, bytes });
    return { ...action, path: `${stateDir}/uploads/${fileName}` };
  });
  return { actions, uploads };
}

function safeFileName(value: string): string {
  const normalized = value.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "");
  if (normalized.length === 0) throw new Error("upload 文件名无可用字符");
  return normalized.slice(0, 120);
}

function statePath(workflow: ExternalFormWorkflow, workflowId: string): string {
  return `${workflow.remoteRoot}/${simpleId(workflowId, "--id")}`;
}

function nextCommand(action: string, source: string, workflowId: string): string {
  return `bun scripts/cli.ts web-probe external-form ${action} --workflow ${shellQuote(source)} --id ${workflowId}`;
}

function requiredWorkflowId(value: string | null): string {
  if (value === null) throw new Error("web-probe external-form 缺少 --id");
  return simpleId(value, "--id");
}

function safeRemoteRoot(value: string): string {
  if (isAbsolute(value) || value.includes("..") || !/^\.state\/[A-Za-z0-9_./-]+$/u.test(value)) throw new Error("spec.state.remoteRoot 必须是 .state/ 下的安全相对路径");
  return value.replace(/\/+$/u, "");
}

function readFixtureHtml(pathArg: string): string {
  if (isAbsolute(pathArg) || pathArg.includes("..")) throw new Error("spec.fixture.htmlPath 必须是仓库内安全相对路径");
  const value = readFileSync(rootPath(pathArg), "utf8");
  if (value.length === 0 || value.length > 256 * 1024) throw new Error("spec.fixture.htmlPath 必须是 1B 到 256KiB 的 HTML 文件");
  return value;
}

function parseRemoteJson(stdout: string): Record<string, unknown> | null {
  const lines = stdout.trim().split(/\r?\n/u).filter(Boolean);
  for (let index = lines.length - 1; index >= 0; index -= 1) {
    try {
      const value = JSON.parse(lines[index]) as unknown;
      if (value !== null && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
    } catch {}
  }
  return null;
}

function externalFormLaunchReadinessSource(): string {
  return String.raw`
const fs = require("fs");
const runnerPid = Number(process.argv[2]);
const stateDir = process.argv[3];
const expectedWorkflowId = process.argv[4];
const timeoutMs = Number(process.argv[5]);
const pollMs = Number(process.argv[6]);
const deadline = Date.now() + timeoutMs;
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
const read = (name) => { try { return JSON.parse(fs.readFileSync(stateDir + "/" + name, "utf8")); } catch { return null; } };
while (Date.now() <= deadline) {
  const alive = fs.existsSync("/proc/" + runnerPid);
  const ready = read("launch-ready.json");
  if (alive && ready?.ok === true && ready.workflowId === expectedWorkflowId && ready.pid === runnerPid && Number.isInteger(ready.browserPid)) {
    console.log(JSON.stringify({ ok: true, workflowId: expectedWorkflowId, runnerPid, browserPid: ready.browserPid, readiness: "runner-state-and-browser", valuesRedacted: true }));
    process.exit(0);
  }
  if (!alive || ready?.ok === false) {
    console.log(JSON.stringify({ ok: false, reason: !alive ? "runner-exited-before-ready" : ready.reason, workflowId: expectedWorkflowId, runnerPid, runnerAlive: alive, valuesRedacted: true }));
    process.exit(43);
  }
  pause(pollMs);
}
console.log(JSON.stringify({ ok: false, reason: "launch-readiness-timeout", workflowId: expectedWorkflowId, runnerPid, runnerAlive: fs.existsSync("/proc/" + runnerPid), valuesRedacted: true }));
process.exit(44);
`;
}

function sha(value: string): string {
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}

function record(value: unknown, label: string): Record<string, unknown> {
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} 必须是对象`);
  return value as Record<string, unknown>;
}

function recordArray(value: unknown, label: string): Record<string, unknown>[] {
  if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} 必须是非空数组`);
  return value.map((item, index) => record(item, `${label}[${index}]`));
}

function text(value: unknown, label: string): string {
  if (typeof value !== "string" || value.length === 0) throw new Error(`${label} 必须是非空字符串`);
  return value;
}

function optionalText(value: unknown, label: string): string | undefined {
  return value === undefined ? undefined : text(value, label);
}

function textArray(value: unknown, label: string): string[] {
  if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} 必须是非空字符串数组`);
  return value.map((item, index) => text(item, `${label}[${index}]`));
}

function positiveInteger(value: unknown, label: string): number {
  if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${label} 必须是正整数`);
  return Number(value);
}

function simpleId(value: string, label: string): string {
  if (!/^[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${label} 只能包含 A-Z、a-z、0-9、点、下划线和连字符`);
  return value;
}

function optionalEnum<T extends string>(value: unknown, allowed: readonly T[], label: string): T | null {
  if (value === undefined) return null;
  if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${label} 必须是 ${allowed.join("|")}`);
  return value as T;
}
