From 68fa6cf3ebf7c1d64a122a02ea8ae2a9f2bb9a9d Mon Sep 17 00:00:00 2001 From: song Date: Wed, 14 Oct 2026 15:35:00 +0800 Subject: [PATCH 01/38] test(ci): model both PR-number fields in concurrency fixtures GitHub's pull_request and pull_request_target payloads expose the PR number as both github.event.number and github.event.pull_request.number. tests/test_ci_resource_policy.py modelled only the nested form, so the python-tests concurrency group, which reads github.event.number, failed test_main_supersession_does_not_cancel_nightly_or_manual_full_qualification on main with AttributeError. scripts/ci/test_review_gate.py modelled only the flat form with pull_request=None. Give both fixtures the real shape so the tests check the cancellation policy rather than one field spelling. No workflow change. Signed-off-by: song --- scripts/ci/test_review_gate.py | 2 +- tests/test_ci_resource_policy.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_review_gate.py b/scripts/ci/test_review_gate.py index ff20a4854b..07f44c2c25 100644 --- a/scripts/ci/test_review_gate.py +++ b/scripts/ci/test_review_gate.py @@ -52,7 +52,7 @@ def test_closed_pr_cancels_same_lane_and_skips_qualification(self): def expression(value, action, *, number=17, event="pull_request"): ref = "refs/heads/main" if action == "closed" or event != "pull_request" else f"refs/pull/{number}/merge" github = SimpleNamespace(event_name=event, ref=ref, event=SimpleNamespace( - action=action, number=number, pull_request=None)) + action=action, number=number, pull_request=SimpleNamespace(number=number))) return eval(value.strip().removeprefix("${{").removesuffix("}}").strip() .replace("&&", "and").replace("||", "or"), {"__builtins__": {}}, {"github": github, "always": lambda: True, diff --git a/tests/test_ci_resource_policy.py b/tests/test_ci_resource_policy.py index 2f3f7a431e..ff0f158b15 100644 --- a/tests/test_ci_resource_policy.py +++ b/tests/test_ci_resource_policy.py @@ -20,7 +20,8 @@ def event(name: str, *, pr: int = 101, tag: str = "", backend: bool = True) -> d "github": SimpleNamespace( event_name=name, ref=f"refs/pull/{pr}/merge" if name == "pull_request" else "refs/heads/main", - event=SimpleNamespace(pull_request=SimpleNamespace(number=pr), release=SimpleNamespace(tag_name=tag)), + # GitHub exposes the PR number both as `event.number` and `event.pull_request.number`. + event=SimpleNamespace(number=pr, pull_request=SimpleNamespace(number=pr), release=SimpleNamespace(tag_name=tag)), ), "inputs": SimpleNamespace(tag=tag), "needs": SimpleNamespace(changes=SimpleNamespace(outputs=SimpleNamespace(backend_tests=str(backend).lower()))), From ee0833128aada01bc17de9e76c34e611e93c014a Mon Sep 17 00:00:00 2001 From: song Date: Wed, 14 Oct 2026 15:36:16 +0800 Subject: [PATCH 02/38] ci: queue superseded main pushes instead of cancelling the running qualification Main pushes to python-tests used cancel-in-progress, so each push cancelled the previous default-branch run. With pushes arriving faster than a full run completes, the newest main almost never reached a result: of the last 100 Python Tests runs on main, 95 were cancelled, 3 failed and none succeeded, and the only completed runs were the nightly schedule. Eight sampled cancelled push runs had already consumed 1,345 runner-minutes (mean 168) before cancellation, producing no signal. Drop push from cancel-in-progress. GitHub's default queue keeps the running run, holds at most one pending run per group, and replaces that pending run when a newer push arrives, so intermediate pushes cost nothing and the newest main always completes. PR heads keep cancelling their own superseded runs; scheduled, manual and merge-queue lanes are unchanged. Rename the policy test that encoded the old default and update the resource cadence doc in both languages. Signed-off-by: song --- .github/workflows/python-tests.yml | 6 +++++- docs/development/ci-impact-selection.md | 12 ++++++++---- tests/test_ci_resource_policy.py | 7 +++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4b413eb057..3cc5bb690e 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -37,7 +37,11 @@ permissions: concurrency: # A merged PR's closed event uses main as github.ref; keep its original PR lane. group: python-tests-${{ github.event_name }}-${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('pr-{0}', github.event.number) || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'push' }} + # Only a PR's own superseded head is cancelled. Main pushes queue: the running + # default-branch qualification completes and at most one newer push waits, + # replacing any older pending push, so the newest main always reaches a result + # without spending runner time on runs that can never finish. + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} env: LOOPX_USAGE_PING: "0" diff --git a/docs/development/ci-impact-selection.md b/docs/development/ci-impact-selection.md index eac77ede20..58cd71f964 100644 --- a/docs/development/ci-impact-selection.md +++ b/docs/development/ci-impact-selection.md @@ -193,9 +193,11 @@ to one and background shard occupancy from five runners to two; the complete sweep may take longer. The existing fleet-health readback still checks receipts. Python Tests also runs daily at `20:17 UTC`. Its event-scoped concurrency keeps -that full run separate from main pushes and PRs. New PR heads and main pushes -cancel superseded runs; scheduled, manual and merge-queue qualification is not -cancelled by that policy. Release-artifact, desktop-artifact, signed-desktop, +that full run separate from main pushes and PRs. A new PR head cancels that +PR's superseded run. Main pushes queue instead: the running default-branch +qualification completes, at most one newer push waits, and a further push +replaces that pending successor before it starts. Scheduled, manual and +merge-queue qualification is never cancelled by this policy. Release-artifact, desktop-artifact, signed-desktop, DCO and optional Ark PR checks cancel only superseded validation for the same PR. Signed-desktop PRs use separate groups from each other and from channel publication. Release publication remains serialized and is never cancelled by a PR update. @@ -203,7 +205,9 @@ Release publication remains serialized and is never cancelled by a PR update. 全量 smoke 改为每天一次与指定 ref 的手动运行,不再随普通主干提交启动。五个完整 分片共享一次源码绑定的前端构建,同一时刻最多运行两个分片;全量用例仍保留, 代价是后台 sweep 可能更久。每日完整 Python 验证与 PR/主干提交使用不同并发组, -避免新提交取消夜间基线。过时 PR 检查可取消,发布通道仍保留串行发布保护。 +避免新提交取消夜间基线。同一 PR 的过时 head 会被取消;主干提交改为排队:运行中的 +主干验证跑完,最多保留一个更新的待运行提交,再新的提交在其启动前替换它。发布通道 +仍保留串行发布保护。 To force a complete smoke sweep before release, open Actions → Full Public Smokes → Run workflow and select the candidate ref. `ci:full` forces full Python diff --git a/tests/test_ci_resource_policy.py b/tests/test_ci_resource_policy.py index ff0f158b15..06a441d9a1 100644 --- a/tests/test_ci_resource_policy.py +++ b/tests/test_ci_resource_policy.py @@ -71,13 +71,16 @@ def test_signed_desktop_prs_do_not_share_either_publication_channel() -> None: assert expression(concurrency["group"], event("schedule")) == expression(concurrency["group"], event("workflow_dispatch")) -def test_main_supersession_does_not_cancel_nightly_or_manual_full_qualification() -> None: +def test_only_pr_heads_cancel_in_progress_and_main_pushes_queue() -> None: + # A main push must never cancel the running default-branch qualification; + # it waits as the single pending successor so the newest main always reaches + # a result. Nightly, manual and merge-queue runs keep their own lanes. policy = workflow("python-tests") assert "schedule" in policy[True] concurrency = policy["concurrency"] contexts = [event(name) for name in ("push", "pull_request", "schedule", "workflow_dispatch", "merge_group")] assert len({expression(concurrency["group"], context) for context in contexts}) == 5 - assert [expression(concurrency["cancel-in-progress"], context) for context in contexts] == [True, True, False, False, False] + assert [expression(concurrency["cancel-in-progress"], context) for context in contexts] == [False, True, False, False, False] def test_required_merge_gate_workflow_is_not_suppressed_by_path_filters() -> None: From 1305980ec76c3cfb00e88f4a6346393cd8dd0dd3 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 14 Oct 2026 15:37:25 +0800 Subject: [PATCH 03/38] docs(ci): record six Python test shards The test-shard matrix and --splits moved from four to six, but the job comments and the testing guide (both languages, including the local reproduce command) still described four lanes and four partitions. Align the readback with the shipped workflow. No behavior change. Signed-off-by: song --- .github/workflows/python-tests.yml | 4 ++-- docs/development/testing-and-quality.md | 28 ++++++++++++------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3cc5bb690e..689e7bb938 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -574,14 +574,14 @@ jobs: needs: [changes, chat-bundle] if: needs.changes.outputs.python_tests == 'true' runs-on: ubuntu-latest - # Share four long-test lanes across current PRs and other workflow runs. + # Share six long-test lanes across current PRs and other workflow runs. # Do not replace another candidate's pending shard or cancel its active head. # Workflow-level concurrency still retires obsolete heads of the same PR. concurrency: 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/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 891d170280..6262e6dd9f 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -122,7 +122,7 @@ shell;不能因为过滤了 `CI` 就恢复采集。不能按系统、安装渠 `python-tests.yml` publishes `merge-gate` for every open pull request. Code, workflow, policy and unknown paths require the existing `pytest` aggregate -(including TypeScript checks and all four Python shards), Stage 2C correctness +(including TypeScript checks and all six Python shards), Stage 2C correctness aggregate, and Windows tests to succeed. Failed, cancelled, missing or unexpectedly skipped results cannot pass the gate. The client-only exception below retains common checks and substitutes packaged Dashboard qualification @@ -203,8 +203,8 @@ The [job exemption policy](ci-impact-selection.md) additionally permits pure Dashboard-client changes to skip backend Python/Windows and Stage2c, while requiring frontend type-check/build, scoped coverage and real packaged Dashboard browser smoke. Backend-only TS/lint/minimum-Node jobs are also exempt. -Mixed, prompt, dependency and unknown changes stay full. Full Python runs four -complete shards and combines all four coverage files. The `ci:full` label forces +Mixed, prompt, dependency and unknown changes stay full. Full Python runs six +complete shards and combines all six coverage files. The `ci:full` label forces full qualification; main stays full. No selected-only report impersonates full coverage. 新的前端豁免由目标分支已审阅的策略控制;CI 自身变更仍全量验证。 @@ -616,7 +616,7 @@ network latency, provider availability, or a two-hour matrix. 它刻意不包含真实模型调用和 full smoke catalog,因此普通迭代不依赖凭证、网络 时延、模型服务可用性或两小时级测试矩阵。 -The Linux suite uses four hosted runners with two xdist workers each. +The Linux suite uses six hosted runners with two xdist workers each. `pytest-split` partitions the complete collection using `least_duration`; without a timing file, tests have equal weight and alternate between shards. Lint, type checks, and the CLI budget run separately. The required `pytest` @@ -630,14 +630,14 @@ An explicitly loaded CI observer also streams pytest-owned phase durations from the xdist controller. Read the downloaded log with `node scripts/ci/pytest_timing.ts JOB_LOG`; incomplete evidence remains incomplete and does not replace JUnit, coverage or the required gate. See the -[timing observation boundary](ci-impact-selection.md#four-complete-python-shards). +[timing observation boundary](ci-impact-selection.md#six-complete-python-shards). The reusable Sonar workflow consumes that same run's XML and never reruns pytest or reads cross-run artifacts. Missing Sonar tokens still skip analysis successfully; test jobs receive no Sonar secret. The trigger is the union of the former Python and Sonar paths. Client-only PRs use the exemption above; Sonar-configuration changes remain full, including on forks without a token. -Linux 全套测试分到四台 hosted runner,每台保留两个 xdist worker。`pytest-split` +Linux 全套测试分到六台 hosted runner,每台保留两个 xdist worker。`pytest-split` 按完整 collection 分片;没有历史耗时时,等权测试交替分配。lint、类型检查和 CLI 预算独立执行。必需的 `pytest` 汇总检查会拒绝失败/跳过的分片和缺失的 coverage, 合并后再执行原有 19.6% 门槛;不要求单个分片达到全套覆盖率。coverage 使用相对路径, @@ -646,12 +646,12 @@ Linux 全套测试分到四台 hosted runner,每台保留两个 xdist worker CI 同时在 xdist controller 流式记录 pytest 原生 phase 耗时。下载日志后用 `node scripts/ci/pytest_timing.ts JOB_LOG` 回读;不完整证据保持不完整,不能替代 JUnit、coverage 或必过门禁。归属与测量边界见 -[分片耗时记录](ci-impact-selection.md#four-complete-python-shards)。 +[分片耗时记录](ci-impact-selection.md#six-complete-python-shards)。 Sonar 只复用同一次 run 的 XML,不重复测试、不跨 run 取产物。缺少 token 仍成功跳过 Sonar,测试 job 不接收 Sonar secret。触发范围取原有两套 workflow 的并集;纯前端 PR 使用前述豁免,Sonar 配置变更仍全量运行,包括没有 token 的 fork。 -Full Python shards share four repository-wide job concurrency groups, one per +Full Python shards share six repository-wide job concurrency groups, one per `matrix.shard`, across PR, main, merge-queue and manual runs carrying this workflow version. Each group admits one running job and uses `queue: max` to retain up to 100 pending jobs instead of replacing another current @@ -666,16 +666,16 @@ unchanged: cancelled, skipped or incomplete dependencies cannot become a green aggregate. This adds one short trusted cleanup job per update without checkout, PR-code execution or new permissions on qualification jobs. Older workflow versions on the base branch retain only closed-event cleanup. Queue overflow, cancellation and missing coverage remain failures of -the required gate; collection, four partitions, two workers, reports and the +the required gate; collection, six partitions, two workers, reports and the coverage floor stay unchanged. Contributors can wait longer for a shard during -overlapping full runs. This limits long-shard occupancy to four runners; it +overlapping full runs. This limits long-shard occupancy to six runners; it does not reserve release capacity or prioritize releases over other jobs or repositories in the organization's shared hosted pool. Older workflow versions do not participate. Roll back by removing this job-level concurrency block, retaining workflow-level cancellation and the required gate. See GitHub's [concurrency queue contract](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). -全量 Python 分片按 `matrix.shard` 共用四个仓库级 job concurrency 组,适用于 +全量 Python 分片按 `matrix.shard` 共用六个仓库级 job concurrency 组,适用于 携带本版 workflow 的 PR、main、merge queue 和手动运行。每组允许一个运行中 job, 用 `queue: max` 保留至多 100 个等待 job,不替换其他有效候选。workflow 级取消 请求清退同 PR 的旧 head;可信 base 分支的 `synchronize` 清理还会取消遗留 @@ -685,13 +685,13 @@ PR、当前 head 和无关联 run 均被保护。必过汇总条件不变,取 不能变绿。每次更新增加一个短清理 job,不 checkout 或执行 PR 代码,不增加普通 验证 job 的权限;base 分支旧版 workflow 仍只清理 closed 事件。队列溢出、取消 和缺失 coverage 仍不能通过必需检查。 -完整 collection、四分片、双 worker、报告和覆盖率门槛保持不变;重叠的全量运行 -会增加贡献者等待分片的时间。此限制只把长分片占用约束到四台 runner,不预留发布 +完整 collection、六分片、双 worker、报告和覆盖率门槛保持不变;重叠的全量运行 +会增加贡献者等待分片的时间。此限制只把长分片占用约束到六台 runner,不预留发布 容量,也不保证发布优先于组织共享 hosted 池中的其他 job 或仓库。旧版 workflow 不参与该限制。回退时移除此 job 的 concurrency 块,保留 workflow 级取消和必需 检查;队列行为遵循上述 GitHub 官方契约。 -Reproduce one shard locally with `uv run --extra test python -m pytest -v -n 2 --splits 4 --group 1 +Reproduce one shard locally with `uv run --extra test python -m pytest -v -n 2 --splits 6 --group 1 --splitting-algorithm least_duration --cov=loopx`. Omit the split arguments to run the complete suite locally. 全量本地测试仍省略分片参数即可。 From 5c13b80fff0563b5eddd3c7f4a098ad0a58d5445 Mon Sep 17 00:00:00 2001 From: song Date: Thu, 15 Oct 2026 08:13:37 +0800 Subject: [PATCH 04/38] Revert "ci: queue superseded main pushes instead of cancelling the running qualification" The maintainer confirmed on #6416 that cancelling a superseded main push is by design, to keep CI efficient. Restore the accepted cancel-in-progress expression, the test that encodes that default, and the supersession prose in ci-impact-selection.md. The webhook-fixture and six-shard readback commits are unchanged. Signed-off-by: song --- .github/workflows/python-tests.yml | 6 +----- docs/development/ci-impact-selection.md | 12 ++++-------- tests/test_ci_resource_policy.py | 7 ++----- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 689e7bb938..709645afd3 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -37,11 +37,7 @@ permissions: concurrency: # A merged PR's closed event uses main as github.ref; keep its original PR lane. group: python-tests-${{ github.event_name }}-${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('pr-{0}', github.event.number) || github.ref }} - # Only a PR's own superseded head is cancelled. Main pushes queue: the running - # default-branch qualification completes and at most one newer push waits, - # replacing any older pending push, so the newest main always reaches a result - # without spending runner time on runs that can never finish. - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'push' }} env: LOOPX_USAGE_PING: "0" diff --git a/docs/development/ci-impact-selection.md b/docs/development/ci-impact-selection.md index 58cd71f964..eac77ede20 100644 --- a/docs/development/ci-impact-selection.md +++ b/docs/development/ci-impact-selection.md @@ -193,11 +193,9 @@ to one and background shard occupancy from five runners to two; the complete sweep may take longer. The existing fleet-health readback still checks receipts. Python Tests also runs daily at `20:17 UTC`. Its event-scoped concurrency keeps -that full run separate from main pushes and PRs. A new PR head cancels that -PR's superseded run. Main pushes queue instead: the running default-branch -qualification completes, at most one newer push waits, and a further push -replaces that pending successor before it starts. Scheduled, manual and -merge-queue qualification is never cancelled by this policy. Release-artifact, desktop-artifact, signed-desktop, +that full run separate from main pushes and PRs. New PR heads and main pushes +cancel superseded runs; scheduled, manual and merge-queue qualification is not +cancelled by that policy. Release-artifact, desktop-artifact, signed-desktop, DCO and optional Ark PR checks cancel only superseded validation for the same PR. Signed-desktop PRs use separate groups from each other and from channel publication. Release publication remains serialized and is never cancelled by a PR update. @@ -205,9 +203,7 @@ Release publication remains serialized and is never cancelled by a PR update. 全量 smoke 改为每天一次与指定 ref 的手动运行,不再随普通主干提交启动。五个完整 分片共享一次源码绑定的前端构建,同一时刻最多运行两个分片;全量用例仍保留, 代价是后台 sweep 可能更久。每日完整 Python 验证与 PR/主干提交使用不同并发组, -避免新提交取消夜间基线。同一 PR 的过时 head 会被取消;主干提交改为排队:运行中的 -主干验证跑完,最多保留一个更新的待运行提交,再新的提交在其启动前替换它。发布通道 -仍保留串行发布保护。 +避免新提交取消夜间基线。过时 PR 检查可取消,发布通道仍保留串行发布保护。 To force a complete smoke sweep before release, open Actions → Full Public Smokes → Run workflow and select the candidate ref. `ci:full` forces full Python diff --git a/tests/test_ci_resource_policy.py b/tests/test_ci_resource_policy.py index 654eba6a02..9cd4dfacac 100644 --- a/tests/test_ci_resource_policy.py +++ b/tests/test_ci_resource_policy.py @@ -72,10 +72,7 @@ def test_signed_desktop_prs_do_not_share_either_publication_channel() -> None: assert expression(concurrency["group"], event("schedule")) == expression(concurrency["group"], event("workflow_dispatch")) -def test_only_pr_heads_cancel_in_progress_and_main_pushes_queue() -> None: - # A main push must never cancel the running default-branch qualification; - # it waits as the single pending successor so the newest main always reaches - # a result. Nightly, manual and merge-queue runs keep their own lanes. +def test_main_supersession_does_not_cancel_nightly_or_manual_full_qualification() -> None: policy = workflow("python-tests") assert "schedule" in policy[True] concurrency = policy["concurrency"] @@ -84,7 +81,7 @@ def test_only_pr_heads_cancel_in_progress_and_main_pushes_queue() -> None: if context["github"].event_name != "pull_request": del context["github"].event.number assert len({expression(concurrency["group"], context) for context in contexts}) == 5 - assert [expression(concurrency["cancel-in-progress"], context) for context in contexts] == [False, True, False, False, False] + assert [expression(concurrency["cancel-in-progress"], context) for context in contexts] == [True, True, False, False, False] @pytest.mark.parametrize("name", ["pull_request", "pull_request_target"]) From 60633ee3dda59ef5f9b6c5e309579a21e92a4046 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:27:58 +0800 Subject: [PATCH 05/38] test(desktop): qualify rollback with released signed app bundles (#6515) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/desktop/loopx-control-plane/README.md | 20 ++++++ .../src-tauri/src/update_backup.rs | 68 +++++++++++++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 77ab956618..a0a0864737 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -305,6 +305,26 @@ 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. + `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/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] From 9d5a936817a5412c73f17ec8704cd59d92651cd8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:29:20 +0800 Subject: [PATCH 06/38] feat(chat): interact with bound Codex App conversations (#6482) * feat(chat): connect bound Codex App conversations to the workspace Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * test(chat): qualify original-owner input and recovery boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * docs(chat): document experimental original Codex conversation provider Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * docs(chat): include synthetic conversation and recovery previews Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * test(chat): capture the explicit original-App open failure state Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * fix(test): validate installed conversations without pytest or source shadowing Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * fix(chat): type attached HTTP composition without mixin overrides Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * fix(chat): keep confirmed original replies ready for follow-up Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * fix(workspace): recover acknowledged replies after response loss Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --------- Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../attached-agent-conversations.css | 22 + .../attached-agent-conversations.tsx | 190 +++++++++ .../personal-workspace-page.tsx | 2 + .../rfcs/desktop-execution-frontends-v0.md | 17 + .../session-runtime-control-plane-adapter.md | 74 ++++ .../attached-host-follow-up.mjs | 2 + .../codex-attached.mjs | 179 ++++++++ .../codex_attached_fixture.py | 120 ++++++ .../codex_attached_peer.py | 224 ++++++++++ .../screenshots/codex-attached/README.md | 19 + .../attached-codex-conversation-desktop.png | Bin 0 -> 118149 bytes .../attached-codex-conversation-phone.png | Bin 0 -> 49423 bytes .../attached-codex-entry-desktop.png | Bin 0 -> 109134 bytes .../attached-codex-open-error-desktop.png | Bin 0 -> 113887 bytes .../attached-codex-pending-desktop.png | Bin 0 -> 122041 bytes .../native_chat/codex_attached.py | 331 +++++++++++++++ loopx/presentation/codex_attached_api.py | 306 ++++++++++++++ loopx/presentation/configuration_api.py | 5 + .../test_codex_attached_conversation.py | 387 ++++++++++++++++++ 19 files changed, 1878 insertions(+) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx create mode 100644 examples/personal-workspace-browser/codex-attached.mjs create mode 100644 examples/personal-workspace-browser/codex_attached_fixture.py create mode 100644 examples/personal-workspace-browser/codex_attached_peer.py create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/README.md create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-phone.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-entry-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-open-error-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-pending-desktop.png create mode 100644 loopx/capabilities/native_chat/codex_attached.py create mode 100644 loopx/presentation/codex_attached_api.py create mode 100644 tests/presentation/test_codex_attached_conversation.py 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..4a64b1ab5c --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -0,0 +1,22 @@ +.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-dialog {position: fixed; inset: 0; margin: auto; width: min(960px, calc(100vw - 24px)); max-height: calc(100dvh - 48px); padding: 0; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); color: var(--pw-text); font: inherit;} +.attached-conversation-dialog[open] {display: flex; flex-direction: column;} +.attached-conversation-dialog::backdrop {background: rgb(0 0 0 / 32%);} +.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;}} 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..035239bdcb --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -0,0 +1,190 @@ +import {useEffect, useRef, useState} from "react"; +import {MarkdownText} from "./markdown"; +import "./attached-agent-conversations.css"; + +type Binding = {agent_id: string; thread_id: string; binding_ref: string}; +type Message = {id: string; role: "user" | "assistant"; text: string; truncated: boolean; timestamp: string | null}; +type Snapshot = {messages?: Message[]; before?: number | null; 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}; + +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 Error(value.error || "Conversation unavailable"); + return value as T; +} + +/** Owner-only original conversation. It never changes the selected executor. */ +export function AttachedAgentConversations({goalId, zh}: {goalId: string; zh: boolean}) { + const [open, setOpen] = useState(false); + 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 [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 generation = useRef(0); + const receiptGeneration = useRef(0); + const revision = useRef(""); + const history = useRef<{source: string; size: number} | null>(null); + const transcript = useRef(null); + const dialog = useRef(null); + useEffect(() => { + if (open && !dialog.current?.open) dialog.current?.showModal(); + else if (!open && dialog.current?.open) dialog.current.close(); + }, [open]); + 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(""); + if (open) void request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`) + .then(value => {if (active) {setAgents(value.agents); setSelected(value.agents[0]?.agent_id ?? "");}}) + .catch(failure => {if (active) setError(String(failure));}); + return () => {active = false;}; + }, [goalId, open, refresh]); + + useEffect(() => { + const current = ++generation.current; + revision.current = ""; + history.current = null; + setOpening(false); setOpenRequested(false); setOpenError(""); + setMessages([]); setSnapshot(null); setBefore(null); setDraft(""); setSubmission(null); setSending(false); setLoadingOlder(false); setError(""); setRecoveryError(""); + if (!binding || !open) return; + try { + 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"); + setDraft(pending.status === "delivered" ? "" : 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 params = new URLSearchParams(query!); + if (revision.current) params.set("revision", revision.current); + try { + const next = await request(`/api/chat/codex-attached?${params}`); + if (current !== generation.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 = !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}; + setMessages(previous => [...new Map([...(reset ? [] : previous), ...next.messages ?? []].map(message => [message.id, message])).values()].sort((a,b) => Number(a.id) - Number(b.id)).slice(-1000)); + if (reset) setBefore(next.before ?? null); + } + setSnapshot(previous => ({...previous, ...next})); setError(""); + if (follow) 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, open]); + + 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() { + if (before === null || !query || loadingOlder) return; + const current = generation.current; + setLoadingOlder(true); + const params = new URLSearchParams(query); params.set("before", String(before)); + try { + const next = await request(`/api/chat/codex-attached?${params}`); + if (current === generation.current) { + if (!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."); + setMessages(previous => [...new Map([...(next.messages ?? []), ...previous].map(row => [row.id, row])).values()].sort((a,b) => Number(a.id) - Number(b.id)).slice(0,1000)); + setBefore(next.before ?? null); + } + } catch (failure) {if (current === generation.current) setError(String(failure));} + finally {if (current === generation.current) setLoadingOlder(false);} + } + + 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); + 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") setDraft(""); + else void readReceipt(pending, current); + revision.current = ""; + } + } catch (failure) {if (current === generation.current) {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") {receiptGeneration.current++; setDraft("");} + } + } catch (failure) {if (current === generation.current && receiptVersion === receiptGeneration.current) setRecoveryError(String(failure));} + } + function nextReply() { + try {sessionStorage.removeItem(cacheKey); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");} + catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");} + } + return
+ + setOpen(false)}> +

{zh ? "Codex App 原会话" : "Original Codex App conversations"}

+
+

{zh ? "每秒同步原会话已保存的消息;逐字流和审批仍在 Codex App。" : "Saved messages sync every second; token streaming and approvals remain in Codex App."}

+ {agents.length ? : !error ?

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

: null} + {recoveryError ?

{recoveryError}

: null} + {error ?

{error}

: null} + {binding ? <> +

{!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} +
+ {messages.map(message =>
{message.role === "user" ? (zh ? "你" : "You") : binding.agent_id} {message.timestamp ? : null}
{message.truncated ?

{zh ? "此消息已截短;完整内容见原 App。" : "Message shortened; open the original App for full content."}

: null}
)} + {!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();}}>