diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 966c43a0a9..cd5b2ad444 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -507,7 +507,7 @@ jobs: group: loopx-python-test-shard-${{ matrix.shard }} queue: max cancel-in-progress: false - # All four shards were still progressing at 83-94% when the 45m job + # All shards were still progressing at 83-94% when the 45m job # deadline cancelled run 37916979857. Reserve setup/reporting headroom # without changing test-level deadlines, collection or worker count. timeout-minutes: 60 diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 77ab956618..7dc229897c 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -50,6 +50,13 @@ previous runtime. Core's installation-only doctor qualifies non-editable wheel fingerprints: a local reuse fence, not publisher or source attestation. Older CLIs without that readback can use the automatic App-owned fallback. +Wheel qualification has a separate ten-second subprocess deadline because it +starts Core, checks the TypeScript runtime and hashes package contents. The +one-second environment/version probes remain unchanged. A slow valid wheel can +therefore qualify without asking the user to repair it; invalid or timed-out +qualification still fails closed, and timed-out process groups are terminated +and reaped. This does not speed up doctor or certify every cold-start workload. + A launch-time `LOOPX_BIN` remains the developer's explicit pin. Development mode also accepts a source runtime without promoting an installation. A previously saved path is a discovery candidate, not a permanent version pin. Corrupt @@ -305,6 +312,43 @@ cd .. npm run build ``` +On macOS, also qualify a released artifact using the opt-in checks below +(run from `src-tauri`, replacing the paths with downloaded release artifacts). +Keep the updater archive's `.sig` file beside it: + +```bash +LOOPX_TEST_APP="/path/to/LoopX.app" cargo test --lib \ + real_release_backup_restores_damage_and_rejects_tampering -- --ignored +LOOPX_TEST_SIGNED_ARCHIVE="/path/to/LoopX.app.tar.gz" cargo test \ + --test signed_bundle -- --ignored +``` + +The first check copies the signed App into temporary directories, then exercises +the production restore path after removing its executable, a sealed runtime +resource, or the entire target bundle. It also rejects a tampered backup before +journaling or replacement and checks that the source artifact remains intact. +The second verifies the updater archive with the shipped public key and rejects +modified bytes. Neither check launches the App or writes to an installed App or +user data. These qualify the native recovery and signature boundaries; they do +not replace packaged UI checks of download failure, restart, or runtime handoff. + +### Recover An Interrupted App Update + +If replacement leaves the App incomplete and a backup is available, the update +panel exposes **Restore previous version** directly. Restore it, then choose +**Restart to finish**. This removes two navigation disclosures from the failure +path; choosing a healthy installation's rollback remains under Advanced options. +The native updater still verifies the backup before replacement. An unavailable +or invalid backup reports its cause without recommending an unavailable runtime +repair. Restoring the App does not overwrite a separately managed CLI. + +| Previous recovery panel | Direct recovery action | +| --- | --- | +| ![Recovery hidden in Advanced options](docs/recovery-before.png) | ![Restore action beside the incomplete-installation message](docs/recovery-after.png) | + +These packaged-panel captures use synthetic native status for presentation +comparison; they do not certify an installed update or an executing task's recovery. + `npm run build` produces the configured platform bundles under `src-tauri/target/release/bundle/`. The release workflow builds macOS `.dmg` and `.app.zip` artifacts on macOS, Windows `.msi` and `.exe` artifacts on diff --git a/apps/desktop/loopx-control-plane/docs/recovery-after.png b/apps/desktop/loopx-control-plane/docs/recovery-after.png new file mode 100644 index 0000000000..e9e1266d82 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/recovery-after.png differ diff --git a/apps/desktop/loopx-control-plane/docs/recovery-before.png b/apps/desktop/loopx-control-plane/docs/recovery-before.png new file mode 100644 index 0000000000..2f4f913e09 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/recovery-before.png differ diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index 25a3c276e7..877f52b70d 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -815,6 +815,11 @@ pub(crate) fn python3_environment() -> (bool, Option) { } const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(1); +// Installation qualification boots Core, checks the TypeScript runtime and +// hashes the installed package. It is not a version-banner query: a valid cold +// wheel can exceed one second. Keep that work bounded without weakening Core's +// qualification or extending the lightweight environment probes. +const INSTALLATION_PROBE_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) fn timed_output(command: Command) -> Option { timed_output_with_timeout(command, VERSION_PROBE_TIMEOUT) @@ -986,7 +991,7 @@ fn runtime_identity_for_executable_with_path( let mut command = Command::new(canonical); configure_runtime_environment(&mut command); command.args(["--format", "json", "doctor", "--installation-only"]); - let output = timed_output(command)?; + let output = timed_output_with_timeout(command, INSTALLATION_PROBE_TIMEOUT)?; if !output.status.success() { return None; } diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs index 8bc670752a..72f0d3b6df 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs @@ -1,5 +1,69 @@ use super::*; +#[cfg(unix)] +fn installation_probe_fixture(body: &str) -> (tempfile::TempDir, PathBuf) { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().expect("fixture directory"); + let bin = root.path().join("bin"); + fs::create_dir(&bin).expect("fixture bin"); + let executable = bin.join("loopx"); + fs::write( + &executable, + format!("#!/bin/sh\n[ \"$*\" = \"--format json doctor --installation-only\" ] || exit 2\n{body}\n"), + ) + .expect("fixture executable"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + (root, executable) +} + +#[test] +#[cfg(unix)] +fn installation_probe_allows_cold_qualification_beyond_version_budget() { + let expected = serde_json::json!({ + "schema_version": "loopx_runtime_identity_v1", + "package_version": "1.4.1", "release_id": null, "source_revision": null, + "package_fingerprint": format!("sha256:{}", "a".repeat(64)), + }); + let payload = serde_json::json!({ + "ok": true, "scope": "installation_only", "service_runtime_identity": expected, + }); + let (_root, executable) = + installation_probe_fixture(&format!("sleep 1.2\nprintf '%s\\n' '{payload}'")); + assert_eq!( + runtime_identity_for_executable(executable.to_str().unwrap()), + Some(expected), + "slow, fully qualified wheels must not be treated as missing runtimes" + ); + let mut banner = Command::new("sh"); + banner.args(["-c", "sleep 2; printf 'Python 3.12.0\\n'"]); + assert!( + timed_output(banner).is_none(), + "version probes keep their short bound" + ); +} + +#[test] +#[cfg(unix)] +fn installation_probe_still_bounds_and_reaps_hung_qualification() { + let (root, executable) = installation_probe_fixture("echo $$ > \"$0.pid\"\nexec sleep 30"); + let started = Instant::now(); + assert!(runtime_identity_for_executable(executable.to_str().unwrap()).is_none()); + assert!( + started.elapsed() < Duration::from_secs(13), + "qualification must remain bounded" + ); + let pid = fs::read_to_string(root.path().join("bin/loopx.pid")).unwrap(); + assert!( + !Command::new("kill") + .args(["-0", pid.trim()]) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(), + "timed out probe must be reaped" + ); +} + #[test] fn status_readiness_is_explicit_and_fails_closed() { assert_eq!(ServiceKind::Status.probe_path(), "/?readiness=1"); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs b/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs index 2ee7ebce2c..1be2e407ae 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs @@ -623,15 +623,71 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] #[ignore = "requires LOOPX_TEST_APP from a signed macOS build"] - fn real_backup_copy_retains_signature() { + fn real_release_backup_restores_damage_and_rejects_tampering() { + use std::os::unix::fs::MetadataExt; + let source = std::env::var("LOOPX_TEST_APP").unwrap(); + let source = Path::new(&source); + let executable_name = declared_bundle_executable(source).unwrap(); + let resource = "Contents/Resources/runtime/identity.json"; + let identity = fs::read(source.join(resource)).unwrap(); let dir = tempfile::tempdir().unwrap(); - let copied = dir.path().join("LoopX.app"); - copy(Path::new(&source), &copied).unwrap(); - assert!(copied - .join("Contents/Resources/runtime/identity.json") - .is_file()); + let temporary_root = dir.path().canonicalize().unwrap(); + let assert_temporary = |path: &Path| { + assert!(path.canonicalize().unwrap().starts_with(&temporary_root)); + }; + let previous = dir.path().join("previous"); + let backup = previous.join("LoopX.app"); + let target = dir.path().join("LoopX.app"); + let executable = target.join("Contents/MacOS").join(executable_name); + let journal = dir.path().join("pending-rollback"); + copy(source, &backup).unwrap(); + copy(source, &target).unwrap(); + assert!(installed_bundle_verifies(&executable)); + + // Only disposable copies are damaged. Exercise the same locator, + // verified copy and atomic replacement used by the recovery button + // with a shipped bundle, including its actual sealed runtime resource. + for damaged in [&executable, &target.join(resource), &target] { + assert_temporary(damaged); + if damaged.is_dir() { + fs::remove_dir_all(damaged).unwrap(); + } else { + fs::remove_file(damaged).unwrap(); + } + assert!(!installed_bundle_verifies(&executable)); + restore_verified_backup(&executable, &previous, || { + // The journal callback must precede replacement: the damaged + // target must still be observable here. + assert!(!installed_bundle_verifies(&executable)); + fs::write(&journal, "rollback").map_err(|error| error.to_string()) + }) + .unwrap(); + assert_eq!(fs::read_to_string(&journal).unwrap(), "rollback"); + assert!(installed_bundle_verifies(&executable)); + assert_eq!(fs::read(target.join(resource)).unwrap(), identity); + assert!(signature_verifies(&backup)); + fs::remove_file(&journal).unwrap(); + } + + // An invalid release backup must fail before journaling or swapping, + // leaving the exact installed directory and its signature intact. + let installed_inode = fs::metadata(&target).unwrap().ino(); + assert_temporary(&backup.join(resource)); + fs::write(backup.join(resource), "tampered").unwrap(); + assert_eq!( + restore_verified_backup(&executable, &previous, || { + panic!("a tampered backup must never reach the commit callback") + }), + Err("backup_failed".into()) + ); + assert_eq!(fs::metadata(&target).unwrap().ino(), installed_inode); + assert!(installed_bundle_verifies(&executable)); + assert!(!journal.exists()); + assert_eq!(fs::read(source.join(resource)).unwrap(), identity); + assert!(signature_verifies(source)); } #[test] diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index fe8f06932e..b40b0f05f5 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -116,7 +116,9 @@ const errors = { app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。", app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。", runtime_pairing_required: "旧版本未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。", - backup_failed: "无法备份当前版本,更新已停止。请检查磁盘空间后重试。", + backup_unavailable: "没有可验证的 App 备份,未执行恢复。请重新安装完整 App;当前窗口仍可保留。", + backup_failed: "App 备份无法保存或未通过完整性校验,替换已停止。请检查磁盘空间与权限,或重新安装完整 App。", + rollback_failed: "恢复 App 未完成,请保持窗口打开。可重试恢复;若仍失败,请重新安装完整 App。", }; function codeText(code, phase) { if (runtimeExplicit && ["runtime_identity_unavailable", "runtime_selection_explicit"].includes(code)) { diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index 23c4fa8257..490ef2a445 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Exercise the shipped delivery view with synthetic status and read-only API fixtures. +// Exercise owner contract readback and unavailable operation controls with synthetic sources. import assert from "node:assert/strict"; import {mkdir} from "node:fs/promises"; import { execFileSync, spawn } from "node:child_process"; @@ -39,9 +39,12 @@ try { const writes = []; let response = structuredClone(snapshot); let responseStatus = 200; + let authorityStatus = 200; await page.addInitScript(language => localStorage.setItem("loopx-pw-locale", language), locale); await page.route("**/api/**", route => { - if (route.request().method() !== "GET") writes.push(route.request().url()); + // Model-option discovery is an existing read-only POST. Every effect + // endpoint, including the new acceptance operation, remains in this ledger. + if (route.request().method() !== "GET" && new URL(route.request().url()).pathname !== "/api/chat/agent-model-options") writes.push(route.request().url()); return route.fulfill({ status: 404, json: { error: "Unavailable in read-only fixture" } }); }); await page.route(url => url.pathname === "/status.json", route => route.fulfill({ json: payload })); @@ -50,6 +53,11 @@ try { assert.equal(new URL(route.request().url()).searchParams.get("goal_id"), "release-demo"); return route.fulfill({ status: responseStatus, json: response }); }); + await page.route("**/api/actions?*", route => route.fulfill({json: {ok: true, schema_version: "loopx_chat_action_list_v1", proposals: []}})); + await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({status: authorityStatus, json: { + ok: true, goal_id: response.acceptance.goal_id, provider_revision: "fixture:current", + goal_acceptance_contract: response.acceptance.goal_acceptance_contract ?? {enabled: false}, + }})); await page.goto(`http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`, { waitUntil: "networkidle" }); const navigation = page.getByRole("button", { name: locale === "en" ? "Open Goal navigation" : "打开 Goal 导航" }); if (await navigation.isVisible()) await navigation.click(); @@ -82,12 +90,25 @@ try { const section = review.locator(".delivery-acceptance-contract"); const refresh = review.getByRole("button", { name: locale === "en" ? "Refresh snapshot" : "刷新快照", exact: true }); const exportButton = review.getByRole("button", { name: locale === "en" ? "Export delivery snapshot" : "导出交付快照", exact: true }); + const openAdvanced = async () => { + const advanced = section.locator(".delivery-check-advanced"); + if (await advanced.isVisible() && await advanced.getAttribute("open") === null) { + await advanced.locator(":scope > summary").focus(); + await page.keyboard.press("Enter"); + assert.notEqual(await advanced.getAttribute("open"), null, "Optional diagnostics are keyboard accessible"); + } + }; const refreshSnapshot = async value => { response = { ...snapshot, acceptance: { ...acceptance, goal_acceptance_contract: value } }; const read = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?")); await refresh.click(); await read; await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled); + // The native check reader is independent of the delivery-map snapshot. + // Reload verifies both read owners; polling/recovery is covered by the + // real SQLite/HTTP/CLI journey. + if (responseStatus === 200) await page.reload({waitUntil: "networkidle"}); + await openAdvanced(); }; await review.locator(".delivery-chain").waitFor(); const baseline = await review.innerText(); @@ -98,35 +119,28 @@ try { assert.equal(await review.innerText(), baseline, "Absent/off contract must preserve the existing view"); } await refreshSnapshot(contract); - assert.equal(await section.getAttribute("open"), null, "Contract starts collapsed"); - const summary = section.locator(":scope > summary"); - await summary.focus(); - await page.keyboard.press("Enter"); - assert.notEqual(await section.getAttribute("open"), null, "Disclosure is keyboard accessible"); const text = await section.innerText(); for (const value of [contract.objective, contract.digest, "release-demo", "todo_confirmed", "todo_unbound", "todo_stale", "recovery"]) assert.ok(text.includes(value)); for (const value of locale === "en" ? ["Task association confirmed", "Task association missing", "Task association stale", "Task associations require confirmation", "Outside the current task gate"] : ["任务关联已确认", "任务关联缺失", "任务关联已过期", "任务关联需要确认", "不属于当前任务门禁范围"]) assert.ok(text.includes(value), value); - assert.equal(await section.locator("button,input,select,textarea").count(), 0, "Readback must offer no mutation controls"); - await section.locator("details > summary").last().click(); - await section.getByText(locale === "en" ? /The local Goal owner/ : /本地 Goal 所有者/).waitFor(); + await section.getByText(locale === "en" ? /Import an existing JSON contract/ : /导入现有 JSON 合同/).waitFor(); for (const [status, en, zh] of [["unverified", "Artifact checks not verified", "产物检查未验证"], ["failed", "Artifact checks failed", "产物检查失败"], ["stale", "Artifact checks stale", "产物检查已过期"], ["partial", "Task checks passed; Goal-wide verification unknown", "任务检查通过;Goal 整体验证未知"], ["accepted", "Artifact checks passed", "产物检查通过"]]) { await refreshSnapshot(verifiedContract(status)); - await section.getByText(locale === "en" ? en : zh, { exact: true }).waitFor(); - assert.ok((await section.innerText()).includes(locale === "en" ? "neither automatically approves or completes the Goal" : "均不会自动批准或完成 Goal")); + await section.getByText(locale === "en" ? en : zh, { exact: true }).first().waitFor(); + assert.ok((await section.innerText()).includes(locale === "en" ? "independent review" : "独立复核")); } await refreshSnapshot({ ...contract, scope: { kind: "selected_work", todo_ids: contract.tasks.map(task => task.todo_id) } }); await section.getByText(locale === "en" ? "Only explicitly selected tasks" : "仅明确选定的任务", { exact: true }).waitFor(); - await section.locator("dl").first().scrollIntoViewIfNeeded(); + await section.locator(".delivery-check-advanced dl").scrollIntoViewIfNeeded(); await page.screenshot({ path: process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR ? `${process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR}/${locale}.png` : `/tmp/loopx-acceptance-scope-${locale}.png` }); const next = { ...verifiedContract("stale"), revision: 8, digest: "b".repeat(64) }; await refreshSnapshot(next); await section.getByText(next.digest, { exact: true }).waitFor(); assert.equal(await section.getByText(contract.digest, { exact: true }).count(), 0); - assert.match(await section.locator("dl").first().innerText(), /8/); + assert.match(await section.locator(".delivery-check-advanced dl").innerText(), /8/); await section.locator("details > summary").first().click(); await section.getByText("c".repeat(64), { exact: true }).waitFor(); - assert.match(await section.locator("dl").last().innerText(), /6/); + assert.match(await section.locator(".delivery-check-result dl").innerText(), /6/); assert.equal(await section.evaluate(element => element.scrollWidth > element.clientWidth + 2), false, "Long digests must wrap at narrow widths"); const downloadPromise = page.waitForEvent("download"); await exportButton.click(); @@ -136,16 +150,20 @@ try { for await (const chunk of stream) markdown += chunk.toString(); assert.ok(markdown.includes(next.digest) && markdown.includes("8") && markdown.includes(contract.objective)); responseStatus = 503; + authorityStatus = 503; await refreshSnapshot(next); - await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor(); + await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor(); + assert.equal(await section.getByRole("button", {name: locale === "en" ? "Check delivery" : "检查交付", exact: true}).count(), 0, + "An unavailable current authority must not offer a command from retained state"); assert.equal(await exportButton.isDisabled(), true, "Failed refresh must not export retained contract as current"); responseStatus = 200; + authorityStatus = 200; response = { ...snapshot, acceptance: { ...acceptance, goal_id: "other-goal", goal_acceptance_contract: next } }; const mismatch = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?")); await refresh.click(); await mismatch; await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled); - await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor(); + await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor(); assert.equal(await section.getByText("other-goal", { exact: true }).count(), 0); await refreshSnapshot({ enabled: false }); assert.equal(await section.count(), 0, "Disabling removes the previous enabled readback"); diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs index c6acf089e3..04015e5eab 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs @@ -47,7 +47,15 @@ assert.notEqual(next.acceptance.goal_acceptance_contract.digest, contract.digest assert.deepEqual(contract, before, "Rendering must not mutate the supplied contract"); console.log("Goal acceptance contract API/export: off parity, association/verification distinction, source/revision and negative cases passed"); -const {projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js"); +const {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js"); +for (const [observation, expected] of Object.entries({current: "accepted", stale: "stale", failed: "failed", recorded: "unverified", unavailable: "unverified"})) { + const value = structuredClone(verifiedContract("accepted")); + value.criteria[0].oracle = {state: observation}; + const before = structuredClone(value); + assert.equal(goalAcceptanceDisplayStatus(value), expected, "Historical success cannot override current file observations"); + assert.equal(projectGoalAcceptanceCriteria(value)[0].state, expected); + assert.deepEqual(value, before, "Display freshness cannot mutate canonical acceptance"); +} for (const status of ["stale", "held", "partial"]) { const value = verifiedContract(status); value.verification.contract_revision = value.revision; diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs index 49447a3e05..83bb04e95f 100644 --- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -133,6 +133,26 @@ try { await verificationGap.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"}); await page.setViewportSize({width: 1512, height: 980}); + // A local pinned check is useful evidence, but cannot authenticate an + // independent reviewer. Keep the existing gap alongside the optional receipt. + validation = {...currentValidation, oracle_checks: [{criterion_id: "report-format", source_id: "report-checker", + source_digest: "sha256:" + "8".repeat(64), checked_at: checkTime, + input_versions: [], output_versions: outputVersions}]}; + await page.route("**/api/chat/sessions/*/loopx", checkedRead); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + const correction = evidence.getByRole("region", {name: "纠偏证据路径", exact: true}); + await correction.getByText("负责人指定的版本固定检查器", {exact: true}).waitFor(); + await verificationGap.waitFor(); + await correction.getByText("检查在声明文件的隔离副本上执行;不代表远程复核者身份或异议已解决。", {exact: true}).waitFor(); + await selectedOutput().waitFor(); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + await page.unroute("**/api/chat/sessions/*/loopx", checkedRead); // A completed source can retain its output bytes while its dependency // chain becomes unavailable. Reading an adoption candidate must withdraw // the earlier correction trace and must never offer a confirmation. diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index cb5b66fba5..989d0b8c81 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1,3 +1,4 @@ +import type {OracleEvidence} from "./goal-acceptance-observation"; import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js"; import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; import { parseTurnStep, type TurnStep } from "./turn-steps.js"; @@ -410,6 +411,7 @@ export class ChatApiError extends Error { } export const typedActionKindSchema = z.enum([ + "goal.acceptance.check", "goal.create", "goal.update", "goal.lifecycle", @@ -1174,7 +1176,7 @@ export type DelegationReadback = { artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string; check_count: number; pinned_file_count: number; checked_at?: string; - output_versions?: Array<{ref: string; sha256: string}>}; + output_versions?: Array<{ref: string; sha256: string}>; oracle_checks?: Array}; task_failure?: {stage: "task_postcondition"; status: "failed" | "inconclusive" | "unavailable"; recovery_kind: "repair_required" | "replan_required"; summary: string; validator_kind: string; turn_key: string; resume_mode: "revalidate_cached_result"}; diff --git a/apps/presentation/dashboard/src/data/delivery-review.ts b/apps/presentation/dashboard/src/data/delivery-review.ts index bf886b7cd7..3eb32199f2 100644 --- a/apps/presentation/dashboard/src/data/delivery-review.ts +++ b/apps/presentation/dashboard/src/data/delivery-review.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; +import { goalAcceptanceDisplayStatus, goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; const refsSchema = z.record(z.string(), z.array(z.string())); const nodeSchema = z.object({ @@ -211,7 +211,7 @@ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels: if (task.reason) rows.push(` ${line(task.reason)}`); if (task.applicable === false) rows.push(` ${copy.notApplicable}`); } - rows.push("", `### ${copy.verification}`, copy.verificationState[contract.status]); + rows.push("", `### ${copy.verification}`, copy.verificationState[goalAcceptanceDisplayStatus(contract)]); if (contract.held_todo_ids.length) rows.push(`${copy.heldTasks}: ${line(contract.held_todo_ids.join(", "))}`); rows.push("", `### ${copy.receipt}`); const receipt = contract.verification; diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts new file mode 100644 index 0000000000..de4f91e505 --- /dev/null +++ b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts @@ -0,0 +1,41 @@ +import {z} from "zod"; +import {goalAcceptanceContractSchema} from "./goal-acceptance-observation"; +import type {TypedActionProposal} from "./chat"; +const responseSchema = z.object({ok: z.literal(true), goal_id: z.string(), provider_revision: z.string(), + goal_acceptance_contract: goalAcceptanceContractSchema, checks_passed: z.boolean().optional()}); +export type AcceptanceReadback = z.infer; +export async function acceptanceRequest(goalId: string, operation?: Record, signal?: AbortSignal): Promise { + const url = "/api/chat/goal-acceptance"; + const response = await fetch(operation ? url : `${url}?goal_id=${encodeURIComponent(goalId)}`, operation + ? {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({...operation, goal_id: goalId}), signal} : {signal}); + if (!response.ok) throw new Error("Acceptance unavailable"); + const result = responseSchema.parse(await response.json()); + if (result.goal_id !== goalId) throw new Error("Acceptance source changed"); + return result; +} + +// Read model only. The existing typed acceptance owner validates the complete +// document and its command/file bindings again at apply time. +const preparedDocument = z.object({objective: z.string().min(1), + scope: z.discriminatedUnion("kind", [z.object({kind: z.literal("all_advancement")}), + z.object({kind: z.literal("selected_work"), todo_ids: z.array(z.string()).min(1)})]), + criteria: z.array(z.object({id: z.string(), description: z.string(), + validation_argv: z.array(z.string()).min(1), + oracle: z.object({output_refs: z.array(z.string())}).passthrough().optional(), + }).passthrough()).min(1), +}).passthrough(); +export function preparedAcceptancePlan(proposals: TypedActionProposal[], goalId: string) { + // Never resurrect an older draft after the newest plan was applied, cancelled + // or invalidated. Ambiguous creation order requires a fresh Agent proposal. + const matching = proposals.filter(proposal => proposal.action_kind === "goal.acceptance.check" + && proposal.context.kind === "goal" && proposal.context.goal_id === goalId + && proposal.normalized_parameters.goal_id === goalId) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + const proposal = matching[0]; + if (!proposal || matching[1]?.created_at === proposal.created_at) return null; + if (proposal.permission_classification !== "local_command_execution" + || !["preview_ready", "deferred"].includes(proposal.status) || proposal.gate || proposal.error + || !proposal.available_transitions.includes("apply")) return null; + const parsed = preparedDocument.safeParse(proposal.normalized_parameters.document); + return parsed.success ? {proposal, document: parsed.data} : null; +} diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts index 118c0101e7..c2be3ac4ce 100644 --- a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts +++ b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts @@ -2,7 +2,11 @@ import { z } from "zod"; const nullableText = z.string().nullable(); // Readback only: the Goal owner supplies association and verification states. -const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ +export const oracleEvidenceSchema = z.object({source_id: z.string(), source_digest: z.string(), checked_at: z.string(), + input_versions: z.array(z.object({ref: z.string(), sha256: z.string()})), + output_versions: z.array(z.object({ref: z.string(), sha256: z.string()}))}); +export type OracleEvidence = z.infer; +export const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ z.object({ enabled: z.literal(false) }), z.object({ enabled: z.literal(true), revision: z.number().int().positive(), digest: z.string().min(1), objective: z.string(), @@ -12,7 +16,11 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ ]).optional(), non_goals: z.array(z.string()), held_todo_ids: z.array(z.string()), status: z.enum(["unverified", "stale", "failed", "partial", "accepted", "held"]), - criteria: z.array(z.object({ id: z.string(), description: z.string() })), + criteria: z.array(z.object({ id: z.string(), description: z.string(), oracle: z.object({ + kind: z.literal("owner_pinned_oracle"), source_id: z.string(), source_digest: z.string(), + input_refs: z.array(z.string()), output_refs: z.array(z.string()), + state: z.enum(["unverified", "recorded", "current", "stale", "failed", "unavailable"]), + }).optional() })), tasks: z.array(z.object({ todo_id: z.string(), state: z.enum(["ready", "unbound", "stale"]), criterion_ids: z.array(z.string()), reason: z.string().optional(), @@ -21,7 +29,7 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ verification: z.object({ operation_id: z.string(), contract_revision: z.number().int().positive(), contract_digest: z.string(), todo_id: z.string().nullable(), - results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable() })), + results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable(), oracle_evidence: oracleEvidenceSchema.optional() })), }).nullable(), }), ]); @@ -67,6 +75,21 @@ export function projectGoalAcceptanceCriteria(contract: Extract) { + if (contract.status !== "accepted" && contract.status !== "partial") return contract.status; + const criteria = projectGoalAcceptanceCriteria(contract); + if (criteria.some(item => item.state === "failed")) return "failed"; + if (criteria.some(item => item.state === "stale")) return "stale"; + if (criteria.some(item => item.state === "unverified")) return "unverified"; + return contract.status; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css new file mode 100644 index 0000000000..c41ae101b3 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -0,0 +1,61 @@ +.attached-agent-conversations {margin: 12px 24px;} +.attached-conversation-entry {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} +.attached-conversation-heading {display: flex; align-items: center; justify-content: space-between; padding: 16px; gap: 16px; border-bottom: 1px solid var(--pw-line);} +.attached-conversation-heading h2 {margin: 0; font-size: var(--text-heading);} +.attached-conversation-heading button {font: inherit; padding: 8px 12px; color: inherit; background: var(--pw-card); border: 1px solid var(--pw-line); border-radius: 6px;} +.attached-conversation-content {padding: 0 16px 16px; font-size: var(--text-body); min-height: 0; overflow: auto;} +.attached-conversation-content > p {color: var(--pw-text);} +.attached-conversation-content label {display: grid; gap: 8px; margin-block: 12px;} +.attached-conversation-content select, .attached-conversation-content textarea {font: inherit; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text);} +.attached-conversation-content textarea {min-height: 88px; width: 100%; resize: vertical;} +.attached-conversation-content button {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} +.attached-conversation-content button:disabled, .attached-conversation-content button[aria-disabled="true"] {opacity: .5; cursor: default;} +.attached-conversation-content .attached-conversation-open {border: 0; padding: 0; border-radius: 0; background: transparent; text-decoration: underline; text-underline-offset: 3px;} +.attached-conversation-messages {max-height: min(40vh, 360px); overflow: auto; overscroll-behavior: contain; margin-block: 16px;} +.attached-conversation-messages article {padding-block: 16px; border-top: 1px solid var(--pw-line); overflow-wrap: anywhere;} +.attached-conversation-messages header {display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;} +.attached-conversation-messages time {font-size: var(--text-caption); color: var(--pw-muted);} +.attached-conversation-messages [data-role="user"] {padding-inline: 12px; background: var(--pw-hover); border-radius: 6px;} +@media (max-width: 640px) {.attached-agent-conversations {margin: 12px;} .attached-conversation-messages {max-height: 48vh;}} + +.attached-conversation-messages [data-role="tool"], .attached-conversation-messages [data-role="activity"] {padding-block: 0; color: var(--pw-muted);} +.attached-conversation-messages details {margin-block: 0;} +.attached-conversation-messages summary {cursor: pointer; min-height: 44px; align-content: center; color: var(--pw-text);} +.attached-conversation-messages pre {font: var(--text-body) / var(--leading-prose) var(--font-mono); color: var(--pw-text); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto;} +.attached-history-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: var(--text-caption);} +.personal-composer-wrap[hidden] {display: none;} +.personal-channel-scroll[data-agent-work="true"] {padding-block: 0; overflow: hidden;} +.personal-channel-scroll[data-agent-work="true"] > .personal-goal-view-panel:not([hidden]) {height: 100%;} +.goal-agent-work-page {height: 100%; display: flex; flex-direction: column; min-width: 0;} +.goal-agent-work-page .attached-conversation-heading {padding: 16px 0; flex-wrap: wrap; gap: 12px;} +.goal-agent-work-page .attached-conversation-heading h2 {overflow-wrap: anywhere;} +.agent-work-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0;} +.agent-work-navigation select, .agent-work-directory button {font: inherit; border: 1px solid var(--pw-line); background: var(--pw-card); color: var(--pw-text); border-radius: 6px; padding: 8px 12px; min-height: 44px; max-width: 100%;} +.agent-work-directory {display: grid; gap: 12px; padding-block: 24px;} +.agent-work-directory button {text-align: left; cursor: pointer;} +.agent-work-unavailable {padding-block: 24px; color: var(--pw-muted); line-height: var(--leading-prose);} +.goal-agent-work-page .attached-conversation-content {flex: 1; display: flex; flex-direction: column; padding: 0 0 16px; overflow: auto;} +.goal-agent-work-page .attached-conversation-content > p {margin-block: 8px;} +.goal-agent-work-page .attached-conversation-source {font-size: var(--text-caption); color: var(--pw-muted);} +.goal-agent-work-page .attached-conversation-messages {flex: 1; min-height: 160px; max-height: none; margin-block: 12px;} +.goal-agent-work-page .attached-conversation-content > button {align-self: flex-start; min-height: 44px;} +.goal-agent-work-page form {flex-shrink: 0; display: flex; gap: 12px; align-items: flex-end; border-top: 1px solid var(--pw-line); padding-top: 8px;} +.goal-agent-work-page form label {margin: 0; flex: 1; min-width: 0;} +.goal-agent-work-page form textarea {min-height: 64px; max-height: 160px;} +.goal-agent-work-page form button[type="submit"] {background: var(--pw-text); color: var(--pw-card); min-height: 44px;} +@media (max-width: 640px) {.agent-work-navigation {width: 100%;} .agent-work-navigation select {flex: 1; min-width: 0;} .goal-agent-work-page .attached-conversation-messages {min-height: 120px;}} + +.attached-connection-status {display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; padding-block: 8px;} +.attached-connection-status p {margin: 0;} +.attached-connection-status .attached-conversation-open {min-height: 44px; flex-shrink: 0;} +@media (max-width: 640px) {.goal-agent-work-page .attached-conversation-heading h2 {font-size: var(--text-lead);} .agent-work-navigation {width: auto; flex: 1;} .agent-work-navigation select {max-width: 140px;} .attached-conversation-heading button {padding-inline: 8px;}} + +.attached-conversation-messages summary > strong {font-weight: 500;} +.attached-conversation-messages summary > time {margin-left: 8px;} +.attached-tool-metadata {font-size: var(--text-caption); margin-top: 8px;} +.attached-conversation-messages .attached-tool-source {font-size: var(--text-caption);} +@media (max-width: 640px) { + .goal-agent-work-page form {flex-wrap: wrap;} + .goal-agent-work-page form label {flex-basis: 100%;} + .goal-agent-work-page form button[type="submit"] {margin-left: auto;} +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx new file mode 100644 index 0000000000..8df54a9f03 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -0,0 +1,284 @@ +import {useEffect, useRef, useState} from "react"; +import {AttachedConversationRecord, type ConversationRecord, type ContentPage} from "./attached-conversation-record"; +import "./attached-agent-conversations.css"; + +type Binding = {agent_id: string; thread_id: string; binding_ref: string}; +type Message = ConversationRecord; +type Snapshot = {messages?: Message[]; before?: number | null; after?: number; has_more?: boolean; reset?: boolean; source_ref?: string; history_size?: number; revision: string; unchanged?: boolean; connected?: boolean; can_reply?: boolean; activity: {state: string}}; +type Submission = {operation_id: string; text: string; status?: string}; + +class ConversationRequestError extends Error { + constructor(message: string, readonly unsubmittedOperation?: string) {super(message);} +} + +async function request(url: string, options?: RequestInit): Promise { + const response = await fetch(url, {...options, signal: AbortSignal.timeout(6_000)}); + const value = await response.json(); + if (!response.ok || value.ok !== true) throw new ConversationRequestError( + value.error || "Conversation unavailable", + response.status === 409 && value.input_not_submitted === true && typeof value.operation_id === "string" ? value.operation_id : undefined, + ); + return value as T; +} + +// A bounded file window may contain only metadata. Advance without making the +// reader click through empty pages; keep each request and the whole burst bounded. +async function readVisiblePage(params: URLSearchParams, current: () => boolean): Promise { + const forward = params.has("after"); + let page = await request(`/api/chat/codex-attached?${params}`); + const newestBoundary = page.after; + const newerAvailable = page.has_more; + for (let windows = 1; windows < 8 && current() && !page.unchanged && !page.reset && !page.messages?.length; windows++) { + const cursor = forward ? (page.has_more ? page.after : null) : page.before; + const previous = params.get(forward ? "after" : "before"); + if (cursor == null) break; + if (previous !== null && (forward ? cursor <= Number(previous) : cursor >= Number(previous))) { + throw new Error("History position did not advance; reconnect to read it."); + } + params.set(forward ? "after" : "before", String(cursor)); + params.set("source_ref", page.source_ref ?? ""); + params.delete("revision"); + const next = await request(`/api/chat/codex-attached?${params}`); + if (next.reset || next.source_ref !== page.source_ref) return next; + page = next; + } + // Backward skipping must not move the tail cursor backwards: those bytes + // contained no visible records, and polling should resume at the original tail. + return forward ? page : {...page, after: newestBoundary, has_more: newerAvailable}; +} + +/** Owner-only original conversation. It never changes the selected executor. */ +export function AttachedAgentConversations({goalId, zh, targetAgentId}: { + goalId: string; zh: boolean; targetAgentId: string; +}) { + const [discovering, setDiscovering] = useState(true); + const [agents, setAgents] = useState([]); + const [selected, setSelected] = useState(""); + const [messages, setMessages] = useState([]); + const [snapshot, setSnapshot] = useState(null); + const [before, setBefore] = useState(null); + const [error, setError] = useState(""); + const [recoveryError, setRecoveryError] = useState(""); + const [sendNotice, setSendNotice] = useState(""); + const [draft, setDraft] = useState(""); + const [submission, setSubmission] = useState(null); + const [sending, setSending] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [opening, setOpening] = useState(false); + const [openRequested, setOpenRequested] = useState(false); + const [openError, setOpenError] = useState(""); + const [refresh, setRefresh] = useState(0); + const [readingHistory, setReadingHistory] = useState(false); + const [unread, setUnread] = useState(false); + const following = useRef(true); + const after = useRef(null); + const historyEpoch = useRef(0); + const generation = useRef(0); + const receiptGeneration = useRef(0); + const revision = useRef(""); + const history = useRef<{source: string; size: number} | null>(null); + const transcript = useRef(null); + const binding = agents.find(agent => agent.agent_id === selected); + const awaitingReceipt = Boolean(submission?.status && submission.status !== "delivered"); + const cacheKey = binding ? `loopx-attached-input:${goalId}:${binding.binding_ref}` : ""; + const query = binding ? new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref}) : null; + + useEffect(() => { + let active = true; + setAgents([]); setSelected(""); setMessages([]); setSnapshot(null); setError(""); + setDiscovering(true); + void request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`) + .then(value => {if (active) {setAgents(value.agents); setSelected(targetAgentId);}}) + .catch(failure => {if (active) setError(String(failure));}) + .finally(() => {if (active) setDiscovering(false);}); + return () => {active = false;}; + }, [goalId, refresh, targetAgentId]); + + useEffect(() => { + const current = ++generation.current; + revision.current = ""; + history.current = null; + after.current = null; following.current = true; historyEpoch.current++; + setReadingHistory(false); setUnread(false); + setOpening(false); setOpenRequested(false); setOpenError(""); + setSendNotice(""); + setMessages([]); setSnapshot(null); setBefore(null); setDraft(""); setSubmission(null); setSending(false); setLoadingOlder(false); setError(""); setRecoveryError(""); + if (!binding) return; + try { + setDraft(sessionStorage.getItem(`${cacheKey}:draft`) ?? ""); + const stored = sessionStorage.getItem(cacheKey); + if (stored) { + const pending = JSON.parse(stored) as Submission; + if (!pending || typeof pending.operation_id !== "string" || typeof pending.text !== "string" || !["pending", "delivered"].includes(pending.status ?? "")) throw new Error("Invalid recovery record"); + if (pending.status === "pending") setDraft(pending.text); + setSubmission(pending); + if (pending.status === "pending") void readReceipt(pending, current); + } + } catch {setRecoveryError(zh ? "浏览器回复恢复记录不可读,暂不能安全发送。请先在原 App 核对。" : "Reply recovery is unreadable; check the original App before clearing it.");} + let timer: ReturnType; + async function read() { + const epoch = historyEpoch.current; + const params = new URLSearchParams(query!); + if (following.current && after.current !== null) params.set("after", String(after.current)); + if (history.current) params.set("source_ref", history.current.source); + if (revision.current) params.set("revision", revision.current); + try { + const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current); + if (current !== generation.current || epoch !== historyEpoch.current) return; + const follow = !transcript.current || transcript.current.scrollHeight - transcript.current.scrollTop - transcript.current.clientHeight < 64; + revision.current = next.revision; + if (!next.unchanged) { + const reset = next.reset || !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size; + history.current = {source: next.source_ref ?? "", size: next.history_size ?? 0}; + if (following.current || reset) { + after.current = next.after ?? null; + setMessages(previous => { + const combined = [...new Map([...(reset ? [] : previous), ...next.messages ?? []].map(message => [message.id, message])).values()].sort((a,b) => Number(a.id) - Number(b.id)); + if (combined.length > 300) setBefore(Number(combined[combined.length - 300].id)); + return combined.slice(-300); + }); + if (reset) {setBefore(next.before ?? null); following.current = true; setReadingHistory(false); setUnread(false);} + } else setUnread(true); + } + setSnapshot(previous => ({...previous, ...next})); setError(""); + if (follow && following.current) requestAnimationFrame(() => transcript.current?.scrollTo({top: transcript.current.scrollHeight})); + } catch (failure) {if (current === generation.current) {setError(String(failure)); setSnapshot(null);}} + finally {if (current === generation.current) timer = setTimeout(read, 1000);} + } + void read(); + return () => {generation.current++; clearTimeout(timer);}; + }, [goalId, binding?.binding_ref]); + + async function openOriginal() { + if (!binding || opening) return; + const current = generation.current; + setOpening(true); setOpenRequested(false); setOpenError(""); + try { + await request<{requested: true}>("/api/chat/codex-attached/open", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})}); + if (current === generation.current) setOpenRequested(true); + } catch (failure) {if (current === generation.current) setOpenError(String(failure));} + finally {if (current === generation.current) setOpening(false);} + } + + async function older(newer = false) { + if ((!newer && before === null) || !query || loadingOlder) return; + const current = generation.current; + const epoch = ++historyEpoch.current; + following.current = false; setReadingHistory(true); + setLoadingOlder(true); + const params = new URLSearchParams(query); + params.set(newer ? "after" : "before", String(newer ? after.current ?? 0 : before)); + if (history.current) params.set("source_ref", history.current.source); + try { + const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current); + if (current === generation.current && epoch === historyEpoch.current) { + if (next.reset || !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size) throw new Error(zh ? "原会话历史已更新,请重新连接。" : "Original history changed; reconnect to read it."); + const previousHeight = transcript.current?.scrollHeight ?? 0; + setMessages(previous => { + const combined = [...new Map([...(next.messages ?? []), ...previous].map(row => [row.id, row])).values()].sort((a,b) => Number(a.id) - Number(b.id)); + const retained = newer ? combined.slice(-300) : combined.slice(0,300); + after.current = newer ? next.after ?? after.current : retained.at(-1)?.end ?? next.after ?? null; + setBefore(newer ? Number(retained[0]?.id) || null : next.before ?? null); + return retained; + }); + if (!newer) requestAnimationFrame(() => {if (transcript.current) transcript.current.scrollTop += transcript.current.scrollHeight - previousHeight;}); + if (newer && !next.has_more) {following.current = true; setReadingHistory(false); setUnread(false);} + setError(""); + } + } catch (failure) {if (current === generation.current) setError(String(failure));} + finally {if (current === generation.current) setLoadingOlder(false);} + } + + function pauseHistory() {following.current = false; setReadingHistory(true);} + function latest() { + historyEpoch.current++; after.current = null; history.current = null; revision.current = ""; + following.current = true; setReadingHistory(false); setUnread(false); setMessages([]); setBefore(null); + } + async function loadContent(record: Message, offset: number): Promise { + const params = new URLSearchParams(query!); + params.set("entry_id", record.id); params.set("content_offset", String(offset)); + params.set("source_ref", history.current?.source ?? ""); params.set("content_ref", record.content_ref ?? ""); + return request(`/api/chat/codex-attached?${params}`); + } + + async function send() { + if (!binding || sending || error || recoveryError || !snapshot?.can_reply || !draft.trim() || awaitingReceipt) return; + const current = generation.current; + const pending = {operation_id: crypto.randomUUID(), text: draft, status: "pending"}; + try {sessionStorage.setItem(cacheKey, JSON.stringify(pending));} + catch {setError(zh ? "无法保存回复回执,未发送。" : "Cannot save reply recovery; nothing was sent."); return;} + receiptGeneration.current++; + setSubmission(pending); setSending(true); setSendNotice(""); + try { + const result = await request<{status: string}>("/api/chat/codex-attached", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({operation_id: pending.operation_id, text: pending.text, goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})}); + if (current === generation.current) { + const delivered = {...pending, status: result.status}; + sessionStorage.setItem(cacheKey, JSON.stringify(delivered)); setSubmission(delivered); + if (result.status === "delivered") {sessionStorage.removeItem(`${cacheKey}:draft`); setDraft("");} + else void readReceipt(pending, current); + revision.current = ""; + } + } catch (failure) { + if (current === generation.current) { + if (failure instanceof ConversationRequestError && failure.unsubmittedOperation === pending.operation_id) { + // Only the owning server can prove no native effect. Keep the draft, + // discard this attempt, and require a fresh explicit send when idle. + try { + sessionStorage.removeItem(cacheKey); + receiptGeneration.current++; + setSubmission(null); setRecoveryError(""); + setSnapshot(previous => previous ? {...previous, can_reply: false} : null); + setSendNotice(zh ? "Agent 正在忙碌或状态尚未确认;消息未发送,草稿已保留。" : "The Agent is busy or its state is unconfirmed; nothing was sent. Your draft is retained."); + } catch {setRecoveryError(zh ? "消息未发送,但浏览器回复恢复记录无法清理。" : "Nothing was sent, but the browser recovery record could not be cleared.");} + } else {setError(String(failure)); void readReceipt(pending, current);} + } + } + finally {if (current === generation.current) setSending(false);} + } + async function readReceipt(pending = submission, current = generation.current) { + // Recover from existing evidence only. Never retry the native effect. + if (!query || !pending || current !== generation.current) return; + const receiptVersion = receiptGeneration.current; + const params = new URLSearchParams(query); params.set("operation_id", pending.operation_id); + try { + const receipt = await request<{status: string}>(`/api/chat/codex-attached?${params}`); + if (current === generation.current && receiptVersion === receiptGeneration.current) { + const next = {...pending, status: receipt.status}; + sessionStorage.setItem(cacheKey, JSON.stringify(next)); setSubmission(next); + setRecoveryError(""); + if (receipt.status === "delivered") {sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setDraft("");} + } + } catch (failure) {if (current === generation.current && receiptVersion === receiptGeneration.current) setRecoveryError(String(failure));} + } + function editDraft(text: string) { + setDraft(text); setSubmission(null); setSendNotice(""); + try {sessionStorage.setItem(`${cacheKey}:draft`, text);} + catch {setRecoveryError(zh ? "草稿无法保存在此浏览器,请复制保留后重试。" : "This browser cannot retain the draft; copy it before retrying.");} + } + function nextReply() { + try {sessionStorage.removeItem(cacheKey); sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");} + catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");} + } + return
+ + {discovering ?

{zh ? "正在连接原会话…" : "Connecting to the original conversation…"}

: !binding && !error ?

{zh ? "此 Agent 暂无已绑定的本机 Codex App 原会话。" : "This Agent has no bound local Codex App conversation."}

: null} + {recoveryError ?

{recoveryError}

: null} + {error ?

{error}

: null} + {sendNotice ?

{sendNotice}

: null} + {binding ? <> +

{zh ? "Codex App · 已保存消息与工具记录自动更新" : "Codex App · saved messages and tools update automatically"}

{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}

+
+ {openRequested ?

{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}

: null} + {openError ?

{openError}

: null} + {before !== null ? : null} + {readingHistory ?
{zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""}
: null} +
{if (event.deltaY < 0) pauseHistory();}} onTouchMove={pauseHistory} onClick={pauseHistory} onKeyDown={event => {if (["ArrowUp", "PageUp", "Home"].includes(event.key)) pauseHistory();}} aria-label={zh ? "原会话消息" : "Original conversation messages"}> + {messages.map(message => )} + {!messages.length && snapshot ?

{zh ? "当前有界历史窗口内没有可显示的消息,可读取更早记录。" : "No displayable messages in this bounded window; read earlier records."}

: null} +
+ {submission?.status ?

{submission.status === "delivered" ? (zh ? "已由原 App 接纳;等待 Agent 结果。" : "Accepted by the original App; awaiting the Agent result.") : (zh ? "投递尚未确认,请先在原 App 核对,不会自动重发。" : "Delivery unconfirmed; check the original App. No automatic resend.")} {submission.status === "pending" ? : null}{awaitingReceipt ? : null}

: null} + {!awaitingReceipt ?
{event.preventDefault(); void send();}}>