diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d3215c14f7..783d58b7ad 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -19,6 +19,23 @@ const configuredChatOrigin = String(import.meta.env?.VITE_LOOPX_CHAT_ORIGIN ?? " .trim() .replace(/\/+$/, ""); +export type ConfigurationBackupResult = { + ok: boolean; status?: string; goal_count: number; machine_configuration_present: boolean; + sha256: string; checkpoint_ref?: string; +}; + +export function exportConfigurationBackup(goalIds?: string[]) { + return requestJson}>("/api/chat/configuration-backup/export", { + method: "POST", body: JSON.stringify(goalIds === undefined ? {} : {goal_ids: goalIds}), + }); +} + +export function restoreConfigurationCheckpoint(backup: Record, execute: boolean) { + return requestJson("/api/chat/configuration-backup/restore", { + method: "POST", body: JSON.stringify({backup, expected_sha256: backup.sha256, execute}), + }); +} + function chatApiUrl(path: string) { if (!configuredChatOrigin || /^https?:\/\//.test(path)) { return path; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/configuration-backup-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/configuration-backup-settings.tsx new file mode 100644 index 0000000000..071078dce2 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/configuration-backup-settings.tsx @@ -0,0 +1,46 @@ +import {useEffect, useState} from "react"; +import {exportConfigurationBackup, restoreConfigurationCheckpoint, type ConfigurationBackupResult} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; + +export function ConfigurationBackupSettings({goalId}: {goalId: string | null}) { + const {locale} = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const [backup, setBackup] = useState | null>(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + useEffect(() => {setBackup(null); setResult(null); setError("");}, [goalId]); + async function perform(action: () => Promise) { + setBusy(true); setError(""); + try {await action();} catch (failure) {setError(String(failure));} + finally {setBusy(false);} + } + return
+ {zh ? "配置备份与恢复" : "Configuration backup and recovery"} +

{zh ? "备份包含私有路径、Goal 设置及设备默认值,需自行保管和审查。凭据文件不包含在此组件中。恢复只建立隔离检查点;启用仍在原配置页面办理。" : "Keep and review this private backup: it includes Goal settings, paths and device defaults. Credential files are outside this component. Recovery creates an isolated checkpoint; activate settings through their existing editors."}

+ + + {backup && result?.status === "preview" ? : null} + {result ?

{result.status === "restored" + ? (zh ? "检查点已恢复并核对;当前设置及存储 provider 未改变。" : "Checkpoint restored and verified. Live settings and storage provider are unchanged.") + : (zh ? "配置备份已核对" : "Configuration backup verified")}: {result.goal_count} Goal · {result.machine_configuration_present ? (zh ? "包含设备配置" : "includes device configuration") : (zh ? "设备配置缺省" : "device configuration absent")} + {result.checkpoint_ref ? {result.checkpoint_ref} : null}

: null} + {error ?

{error}

: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index 01a66a8ad5..55acf4d7ad 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -8,6 +8,7 @@ import { GoalCapabilitySettings } from "./goal-capability-settings"; import { GoalOwnershipSettings } from "./goal-ownership-settings"; import { AutomationCadenceSettings } from "./automation-cadence-settings"; import { MachineConfigurationSettings } from "./machine-configuration-settings"; +import { ConfigurationBackupSettings } from "./configuration-backup-settings"; import { OperatorCredentialSettings } from "./operator-credential-settings"; import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model"; import type { WorkspaceTheme } from "./workspace-theme"; @@ -221,6 +222,7 @@ export function WorkspaceSettingsPage({ /> ) : null} {tab === "ownership" && selectedGoal ? : null} + {tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ? : null} {tab === "cadence" && selectedGoal ? : null} {tab === "appearance" ? ( diff --git a/docs/architecture/rfcs/STATUS.md b/docs/architecture/rfcs/STATUS.md index c16dd68b3a..6a3df593d2 100644 --- a/docs/architecture/rfcs/STATUS.md +++ b/docs/architecture/rfcs/STATUS.md @@ -61,7 +61,7 @@ appendix may keep dated history, but no dated log heading may precede it. | [RFC: Research Exploration Control Plane v0](research-exploration-control-plane-v0.md) | Accepted | none | — | | [RFC: Semantic Vocabulary Convergence and Commit-Time Drift Checks (v0)](semantic-vocabulary-convergence-v0.md) | Accepted | none | [5 entries](ledger/semantic-vocabulary-convergence-v0/) | | [RFC: Shared Goal Alignment and Governed Amendment Protocol (v0)](shared-goal-alignment-and-governed-amendment-v0.md) | Accepted | none | [2 entries](ledger/shared-goal-alignment-and-governed-amendment-v0/) | -| [RFC: LoopX Shared Control-Plane Authority and Pluggable State Providers (v0)](shared-goal-authority-state-provider-v0.md) | Accepted | none | [23 entries](ledger/shared-goal-authority-state-provider-v0/) | +| [RFC: LoopX Shared Control-Plane Authority and Pluggable State Providers (v0)](shared-goal-authority-state-provider-v0.md) | Accepted | none | [24 entries](ledger/shared-goal-authority-state-provider-v0/) | | [RFC: Single-Owner Local Daemon (v0)](single-owner-local-daemon-v0.md) | Accepted | none | — | | [RFC: TypeScript Control-Plane Migration Direction v0](typescript-control-plane-migration-v0.md) | Accepted | none | [14 entries](ledger/typescript-control-plane-migration-v0/) | diff --git a/docs/architecture/rfcs/STATUS.zh-CN.md b/docs/architecture/rfcs/STATUS.zh-CN.md index fe9189d5b8..33c158d11e 100644 --- a/docs/architecture/rfcs/STATUS.zh-CN.md +++ b/docs/architecture/rfcs/STATUS.zh-CN.md @@ -58,7 +58,7 @@ | [RFC:研究型探索控制面 v0](research-exploration-control-plane-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:语义词表收敛与提交期漂移检查(v0)](semantic-vocabulary-convergence-v0.zh-CN.md) | 已接受 | 无 | [5 条](ledger/semantic-vocabulary-convergence-v0/) | | [RFC:共享 Goal 对齐与受治理 Amendment 协议(v0)](shared-goal-alignment-and-governed-amendment-v0.zh-CN.md) | 已接受 | 无 | [2 条](ledger/shared-goal-alignment-and-governed-amendment-v0/) | -| [RFC:LoopX 共享控制面权威与可插拔状态 Provider(v0)](shared-goal-authority-state-provider-v0.zh-CN.md) | 已接受 | 无 | [23 条](ledger/shared-goal-authority-state-provider-v0/) | +| [RFC:LoopX 共享控制面权威与可插拔状态 Provider(v0)](shared-goal-authority-state-provider-v0.zh-CN.md) | 已接受 | 无 | [24 条](ledger/shared-goal-authority-state-provider-v0/) | | [RFC: Single-Owner Local Daemon (v0)](single-owner-local-daemon-v0.md) | 已接受 | none | — | | [RFC:LoopX 控制面 TypeScript 渐进迁移方向 v0](typescript-control-plane-migration-v0.zh-CN.md) | 已接受 | 无 | [14 条](ledger/typescript-control-plane-migration-v0/) | diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-10-03-delegation-stop-lease-fence.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-10-03-delegation-stop-lease-fence.md new file mode 100644 index 0000000000..132a8af72e --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-10-03-delegation-stop-lease-fence.md @@ -0,0 +1,188 @@ +# Proposed delegated operation stop: canonical lease revocation + +- Baseline: `e55489c77`, measured October 3, 2026. +- Outcome: overall roadmap S4 ("restart/cancel/drain/stop retain work and + fence old executors") and the R2 bounded single-operation stop; the + revocation half of delivery 2 in the + [September 27 host-supervision plan](2026-09-27-host-supervision.md) + ("cancellation on expiry/reclaim/revocation"). No provider, capability, + configuration surface or lease vocabulary is introduced. +- Status: proposed alternative, pending an explicit maintainer decision. + This entry neither replaces the stop contract under review in #5308 nor + claims that the stop surface has shipped. Its implementation qualification + applies only if this alternative is selected. +- [中文](2026-10-03-delegation-stop-lease-fence.zh-CN.md). + +## What was measured + +[#5308](https://github.com/loopx-project/loopx/pull/5308) is open again. Its +[October 3 review](https://github.com/loopx-project/loopx/pull/5308#pullrequestreview-5401677786) +requires canonical lease-obligation readback (R1), complete execution drain +observation in the existing Host boundary (R2), and a typed separation between +next actions and final receipts (R3). That review explicitly removes historical +failure-by-failure attribution as a merge prerequisite. Earlier failures remain +historical evidence, not proof that its current design cannot be repaired. + +The two proposals address the same caller outcome with different guarantees: + +| Boundary | #5308 under review | This proposed alternative | +| --- | --- | --- | +| Ordering | Prove the original execution drained before releasing its lease | Revoke the lease first; observe drain separately | +| Completion feedback | `settled` requires ACK, released holders, Host drain and resolved lease obligation | `revoked` proves loss of commit authority; only `drained` reports execution exit | +| Authority modes | Includes existing unleased routes with the dispatch fence | Requires canonical `hard_lease`; refuses unleased routes | + +These are alternative public contracts, not interchangeable phase names. Do +not implement both under the same `delegation stop` / `stop_delegation` entry +points. The current #5308 repair follows R1–R3. Selecting this alternative would +require an explicit supersession decision, the CLI/MCP/readback/docs companions, +and the implementation evidence below; merging a design note alone does not +change the runtime contract. Neither approach establishes whole-team or Goal +completion, and neither makes revocation proof of physical drain. + +At the measured baseline, `main` already provides reusable lease fencing and +supervision: + +- `Delegations._complete_delegated_todo` refuses to commit without the + execution's acquired lease and completes through the canonical lease CAS + ([#5466](https://github.com/loopx-project/loopx/pull/5466)). +- `runLeasedHostProcess` re-proves the original owner/key/epoch at + `min(30 s, remaining/2)` and requests cancellation when a renewal is + rejected, current proof is lost or the last proven `expires_at` passes; + the forced group termination follows a six-second + grace ([#5436](https://github.com/loopx-project/loopx/pull/5436)). Each + lease command may run for 60 seconds and a lost reply is retried once with + the same intent, while the proven expiry stays armed throughout: + `tests/control_plane/test_leased_host_process.py::test_real_renewal_faults_keep_original_deadline_and_identity` + shows on real File and SQLite authority that a hung renewal does not + disarm expiry-driven cancellation in the tested supervisor topology. +- `tests/test_delegation_lease_lifetime.py::test_real_revocation_or_new_execution_stops_nested_host_without_acceptance` + proves on real File and SQLite authority, with functioning nested + supervision, that releasing that lease stops the nested Host and its + descendants before the worker returns, leaves the Todo + open, and that retrying the operation neither reacquires the old execution + nor launches the Host again. + +A replay of the retired acquire receipt is the only way the old execution key +can reach the authority again. The acquire receipt identity is deterministic +in `(goal_id, todo_id, owner, idempotency_key)`, and replay requires current +proof, so a released lease retires its key permanently without a new status. + +## Proposed contract + +1. **Scope.** One authorized, bound delegated operation on the local host + authority. Not a team or Goal stop, not coordinator pause, not cross-host + signalling, not a frontend control beyond a recorded-state label. +2. **Intent.** `stop` is the explicit intent of the binding's requester for + one operation. It is persisted beside the operation record before any + fence write, carries the requester identity and one stable `stop_id`, and + cannot be inferred from a signal, a timeout or progress. Repeated stops + return the same receipt. +3. **Fence.** The execution's own canonical hard lease + (`owner`, `idempotency_key`, `lease_epoch`) is the only fence. The + Delegations host releases it through the existing canonical lifecycle with + a CAS on the current version, retrying only a version-mismatch race. This + is the same trust the host already exercises when it claims, renews and + completes on the member's behalf. After release the authority rejects + every renewal, completion CAS and acquire replay from that execution; late + Todo completion and result acceptance are impossible by construction. No + file lock, worker acknowledgement, lane probe or process-group record is + part of this canonical-write guarantee. It does not undo shell commands, + network requests or other external effects already launched by the Host. +4. **Receipt.** The typed TypeScript owner derives one phase from current + facts on every read; no phase is persisted. + - `requested`: intent persisted, the execution has not yet exposed a lease + to release. Read again; the worker observes the intent before it + launches a Host. + - `revoked`: the release committed, or the execution is already fenced by + another epoch or by expiry. The old execution cannot commit effects + guarded by canonical authority. This alone does not qualify overlapping + external work or a resource handoff. + - `drained`: additionally, the existing Host owner proves that the original + execution and every attributed process group have exited, or proves + that no Host was launched and no launch remains possible. A returned + leased supervisor, a `stopped` operation or elapsed grace is insufficient. + - `noop`: the operation was `accepted` or `rejected` before the fence took + effect. Its prior conclusion stands and nothing is written. + Drain is an observation, never a settlement condition. A dead worker + leaves `revoked` with `host_supervision: unobserved`; only complete Host + evidence tied to the original execution can establish drain. An unavailable + or interrupted inner supervisor leaves drain unproven even after outer return. +5. **Worker observation.** The worker checks the intent before acquiring a + lease and again before launching a Host, releases its own lease on either + checkpoint, and records `stopped` after any supervised execution returns + while the intent exists. That observation says the worker handled stop; it + does not prove complete drain. Those checkpoints avoid wasted work; the + canonical-write guarantee comes from the lease fence. +6. **Authority mode.** Stop requires the Goal's canonical `hard_lease` mode. + On `legacy` or `soft_claim` authority there is no execution lease and + therefore no fence; `stop --execute` is refused before any write with a + reason naming the mode. Promoting the Goal is the enabling step. +7. **Lifecycle.** A stopped operation refuses `resume`; continuing requires a + new operation, which acquires a new lease epoch. Stop never completes the + Todo, settles the Goal or changes an accepted result. +8. **Drain latency.** Revocation and resource exit are separate facts. The + fence holds once release commits. The existing leased supervisor requests + cancellation on rejected renewal, failed current proof or its last proven + expiry; that expiry remains armed while authority replies are in flight. + These are cancellation triggers, not an unconditional deadline for every + nested process to exit. Outer supervisor return and expiry plus six-second + grace do not prove inner drain if a nested supervisor is interrupted or its + cleanup cannot be observed. The roughly thirty-six-second healthy path is + nominal only, requiring promptly answered authority requests, no in-flight + renewal at release and functioning supervision. Slow/lost replies or failed + cleanup must remain visible as `revoked` with unproven drain. Report actual + Host evidence before `drained`; this proposal adds no hard drain deadline, + new cleanup service or second process-lifecycle owner. +9. **Surfaces.** CLI `delegation stop --execute` and MCP `stop_delegation` + share `Delegations.stop`; `read`, `wait` and the inventory expose the + receipt and the `stopped` observation. The dashboard shows a recorded + stop, not a claim that execution resources were released. + +## Decisions proposed here + +- **D1, hard-lease only.** This reduces the stop guarantee to the canonical + lease fence, at the cost of refusing existing unleased routes. #5308 instead + retains their dispatch-fence path; that tradeoff needs a maintainer decision. +- **D2, release instead of a new `revoked` lease status.** A new status would + extend a vocabulary consumed by lifecycle, proof, retirement, migration and + recovery owners; release already retires the key, as measured. +- **D3, drain reported, not required for revocation.** This makes authority + loss observable while processes may still be running. It does not satisfy + #5308's `settled` promise or qualify immediate resource handoff. + +## Qualification the implementation owes + +The implementation PR shows each of these on real processes against File and +SQLite authority, records the observed release-to-drain durations, and never +asserts the nominal thirty-six seconds: + +- **Healthy revocation, the positive control.** + `test_real_revocation_or_new_execution_stops_nested_host_without_acceptance` + keeps passing: releasing the lease stops the nested Host and its + descendants before the worker returns, and the Todo stays open. +- **Release while a renewal is in flight.** With a long TTL (for example 180 + seconds), the stop's release commits after a renewal has started and while + that renewal's authority reply is delayed. The receipt is `revoked` once + the release commits and does not report `drained` while the nested Host is + still running. No renewal, Todo completion or acceptance from the old + execution commits. Observe the existing cancellation trigger separately + from complete Host exit; retain `revoked` whenever drain cannot be proved. +- **Lost authority replies.** When the renewal command fails twice or never + answers within its timeout, the same receipt and fence properties hold, + and the last proven expiry remains armed for cancellation. A cancellation + observation is not complete nested-process drain. +- **Interrupted nested supervisor.** Pause the inner supervisor after the + actual Host starts, release the original canonical lease, and wait for the + outer call to return. If an independently observed descendant still runs, + including after expiry plus grace, the receipt must remain `revoked` with + unproven drain. Only subsequent complete, original-execution Host evidence + may report `drained`. Retain the healthy-supervisor control and ensure the + fixture cleans its own groups even when the assertion fails. + +## What this entry does not establish + +The stop surface is not implemented by this entry. Windows native drain, +PostgreSQL re-qualification, cross-host stop, Lark controls, whole-team stop +and installed-product acceptance are outside the slice. Lease records are +opaque JSON to the File, SQLite and PostgreSQL providers, so no provider +change is expected, but that is a reviewed claim of the implementation PR. diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-10-03-delegation-stop-lease-fence.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-10-03-delegation-stop-lease-fence.zh-CN.md new file mode 100644 index 0000000000..0a4424e120 --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-10-03-delegation-stop-lease-fence.zh-CN.md @@ -0,0 +1,147 @@ +# 委派停止替代提案:canonical lease 撤销 + +- 基线:`e55489c77`,2026 年 10 月 3 日测量。 +- 结果:总体 roadmap S4("restart/cancel/drain/stop 保留工作并 fence 旧执行者") + 与 R2 的有界单操作停止;对应 + [9 月 27 日 host-supervision 计划](2026-09-27-host-supervision.zh-CN.md) + 中交付 2 的撤销部分("到期/回收/撤销时取消")。不引入 provider、 + capability、配置面或 lease 词表。 +- 状态:替代提案,待维护者明确决定。本条目不替换 #5308 正在评审的停止契约, + 也不声称停止能力已交付。只有选定本替代方案后,下述实现验收才适用。 +- [English](2026-10-03-delegation-stop-lease-fence.md)。 + +## 测量到什么 + +[#5308](https://github.com/loopx-project/loopx/pull/5308) 已重新打开。 +[10 月 3 日最新评审](https://github.com/loopx-project/loopx/pull/5308#pullrequestreview-5401677786) +要求从 canonical authority 读回租约义务(R1)、由既有 Host 边界提供完整执行的 +退出观察(R2),以及在类型化 owner 中区分下一步动作和最终回执(R3)。该评审 +已明确取消逐次追查历史失败原因这一合入前置条件。旧失败仍是历史证据,不能据此 +断言当前设计无法修复。 + +两份方案服务于同一调用者结果,但保证不同: + +| 边界 | #5308 正在评审的实现 | 本替代提案 | +| --- | --- | --- | +| 顺序 | 先证明原执行退出,再释放其租约 | 先撤销租约,单独观察进程退出 | +| 完成反馈 | `settled` 要求 ACK、holder 释放、Host 退出和租约义务已解析 | `revoked` 证明提交权限失效;只有 `drained` 报告执行退出 | +| authority 模式 | 通过 dispatch fence 保留既有无租约路径 | 要求 canonical `hard_lease`,拒绝无租约路径 | + +这是两份替代的公共契约,不是可以互换的 phase 名称,不能同时实现在同一个 +`delegation stop` / `stop_delegation` 入口下。当前 #5308 的修复遵循 R1–R3。 +选择本替代方案需要明确的替代决定、CLI/MCP/读回/文档的配套修改,以及下述实现 +验收;仅合入设计文档不会改变运行时契约。两者均不代表团队或 Goal 已完成, +也不能用撤销权限证明物理进程已退出。 + +在测量基线上,`main` 已提供可复用的租约 fence 和 supervision: + +- `Delegations._complete_delegated_todo` 没有本次执行已取得的 lease 就拒绝提交, + 并通过 canonical lease CAS 完成 + ([#5466](https://github.com/loopx-project/loopx/pull/5466))。 +- `runLeasedHostProcess` 以 `min(30 s, remaining/2)` 的节奏重新证明原 + owner/key/epoch,在续期被拒绝、当前证明丢失或最后已证明的 `expires_at` 到达时 + 请求取消;强制进程组终止前有六秒 grace + ([#5436](https://github.com/loopx-project/loopx/pull/5436))。每个 lease 命令 + 最长运行 60 秒,回复丢失时以同一意图重试一次,而已证明的到期计时始终有效: + `tests/control_plane/test_leased_host_process.py::test_real_renewal_faults_keep_original_deadline_and_identity` + 在真实 File 与 SQLite authority 上证明,在该测试的监督结构内,续期挂起不会 + 撤销由到期时刻触发的取消。 +- `tests/test_delegation_lease_lifetime.py::test_real_revocation_or_new_execution_stops_nested_host_without_acceptance` + 在真实 File 与 SQLite authority 上证明:嵌套监督正常运行时,释放该 lease 后, + 嵌套 Host 及其子进程 + 在 worker 返回前停止,Todo 保持未完成,重试该操作既不会重新取得旧执行也不会 + 再次启动 Host。 + +旧执行 key 再次到达 authority 的唯一途径是重放已退役的 acquire 回执。acquire +回执身份由 `(goal_id, todo_id, owner, idempotency_key)` 确定性生成,而重放要求 +当前证明,因此 lease 一旦 released,其 key 就永久退役,不需要新状态。 + +## 提议的契约 + +1. **范围。** 本机 authority 上一个经授权、有 binding 的委派操作。不是团队或 + Goal 停止,不是协调者暂停,不是跨宿主信号,前端只有一个记录状态标签。 +2. **意图。** `stop` 是 binding 的 requester 对一个操作的明确意图。它在任何 + fence 写入之前持久化在操作记录旁,携带 requester 身份和一个稳定的 + `stop_id`,不能由信号、超时或进度推导。重复 stop 返回同一回执。 +3. **Fence。** 该执行自己的 canonical hard lease(`owner`、`idempotency_key`、 + `lease_epoch`)是唯一的 fence。Delegations host 通过既有 canonical lifecycle + 以当前 version 的 CAS 释放它,只重试 version 不匹配这一种竞争。这与 host + 代表成员 claim、renew、complete 时行使的是同一份信任。释放之后,authority + 拒绝该执行的一切续期、完成 CAS 与 acquire 重放;迟到的 Todo 完成与结果 + 验收在构造上不可能。文件锁、worker ACK、lane 探测、进程组记录都不是保证的 + 一部分。该保证只约束经过 canonical authority 校验的写入,不能撤回 Host 已经 + 发出的 shell 命令、网络请求或其他外部副作用。 +4. **回执。** 类型化的 TypeScript owner 在每次读取时由当前事实推导一个 + phase;不持久化 phase。 + - `requested`:意图已持久化,执行尚未暴露可释放的 lease。再次读取;worker + 会在启动 Host 前观察到该意图。 + - `revoked`:释放已提交,或该执行已被另一个 epoch 或到期 fence。旧执行不能 + 提交受 canonical authority 校验的效果;这本身不证明可以重叠执行外部工作 + 或交接资源。 + - `drained`:在此之上,既有 Host owner 证明原执行及全部归属进程组已经退出, + 或证明 Host 从未启动且已不存在继续启动的可能。leased supervisor 返回、 + 操作记录为 `stopped` 或 grace 已经过期,都不足以证明这一点。 + - `noop`:操作在 fence 生效前已经 `accepted` 或 `rejected`。原结论保留, + 不写任何内容。 + Drain 是观察,绝不是结算条件。worker 已死时停留在 `revoked` 且 + `host_supervision: unobserved`;只有绑定原执行的完整 Host 证据才能建立 drain。 + 内层 supervisor 不可用或被中断时,即使外层已经返回,drain 仍未获证明。 +5. **Worker 观察。** worker 在取得 lease 前和启动 Host 前各检查一次意图,任一 + 检查点命中时释放自己的 lease;在意图存在时任何被监督执行返回后记录 + `stopped`。这只说明 worker 处理过停止,不能证明完整 drain。检查点用于避免 + 浪费工作;canonical 写入保证来自 lease fence。 +6. **Authority 模式。** stop 要求 Goal 的 canonical `hard_lease` 模式。在 + `legacy` 或 `soft_claim` authority 上没有执行 lease,因此没有 fence; + `stop --execute` 在任何写入前被拒绝,原因中写明模式。提升 Goal 是启用步骤。 +7. **生命周期。** 已停止的操作拒绝 `resume`;继续需要新操作,它会取得新的 + lease epoch。stop 永不完成 Todo、不结算 Goal、不改变已验收结果。 +8. **Drain 延迟。** 撤销与资源退出是两个事实。release 提交后 fence 生效;既有 + leased supervisor 在续期被拒绝、当前证明失败或最后已证明的到期时刻请求取消, + authority 回复在途时也保留这个到期计时器。这些是取消触发条件,不是所有嵌套 + 进程退出的无条件期限。内层 supervisor 被中断或无法观察其清理时,外层返回和 + 到期加六秒 grace 都不能证明内层 drain。约三十六秒的健康路径只是名义值,要求 + authority 及时响应、release 时没有在途续期且监督正常运行。慢响应、回复丢失或 + 清理失败时,保留 `revoked` 与 drain 未证明的事实。只有真实完整的 Host 证据才 + 能得到 `drained`;本提案不新增 drain 硬期限、清理服务或第二个进程生命周期 owner。 +9. **入口。** CLI `delegation stop --execute` 与 MCP `stop_delegation` 共用 + `Delegations.stop`;`read`、`wait` 与 inventory 暴露回执与 `stopped` 观察。 + dashboard 展示"停止已登记",不声称执行资源已释放。 + +## 本条目提议的决定 + +- **D1,仅限 hard lease。** 把停止保证限定在 canonical lease fence,代价是 + 拒绝既有无租约路径。#5308 保留这些路径的 dispatch fence,取舍需由维护者决定。 +- **D2,用 release 而非新增 `revoked` lease 状态。** 新状态会扩展被 + lifecycle、proof、retirement、migration 与 recovery 多个 owner 消费的词表; + 如测量所示,release 已经使 key 退役。 +- **D3,drain 单独报告,不作为撤销的条件。** 允许在进程仍运行时报告提交权限 + 已失效;这不满足 #5308 的 `settled` 承诺,也不证明可以立即交接执行资源。 + +## 实现 PR 必须给出的验收 + +实现 PR 需在 File 与 SQLite authority 上以真实进程逐项证明以下各点,记录观测到的 +release 到 drain 耗时,且从不断言名义上的三十六秒: + +- **健康撤销,作为正向对照。** + `test_real_revocation_or_new_execution_stops_nested_host_without_acceptance` + 继续通过:释放 lease 后,嵌套 Host 及其子进程在 worker 返回前停止,Todo 保持 + 未完成。 +- **续期进行中时 release。** 使用长 TTL(例如 180 秒),在一次续期已开始、且其 + authority 回复被延迟时提交 stop 的 release。release 提交后回执即为 `revoked`, + 嵌套 Host 仍在运行时不报告 `drained`。旧执行的续期、Todo 完成与验收都不能提交; + 分别观察既有取消触发和完整 Host 退出;不能证明 drain 时保留 `revoked`。 +- **authority 回复丢失。** 续期命令两次失败或在超时内始终无回复时,回执与 fence + 的性质不变,最后已证明的到期计时器仍负责触发取消。取消观察不等于完整嵌套 + 进程 drain。 +- **内层 supervisor 中断。** 实际 Host 启动后暂停内层 supervisor,释放原 canonical + lease,等待外层调用返回。若独立观察到后代仍在运行,包括到期加 grace 之后, + 回执必须保持 `revoked` 且 drain 未证明。只有后续绑定原执行的完整 Host 证据才能 + 报告 `drained`。保留 supervisor 正常运行的正控,并保证断言失败时 fixture 仍清理 + 自己的进程组。 + +## 本条目不建立什么 + +停止能力不由本条目实现。Windows 原生 drain、PostgreSQL 重新资格化、跨宿主 +停止、Lark 控件、整团队停止与安装态验收都在切片之外。lease 记录对 File、 +SQLite、PostgreSQL provider 是不透明 JSON,预计不需要 provider 改动,但这是 +实现 PR 需要评审的声明。 diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 4a11700635..f6efd1a772 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -49,6 +49,7 @@ format migration; legacy decoding exists only in the migration owner. File and SQLite reuse logical archives for cross-provider isolated recovery. This adds no provider/default promotion and retires no Python business owner. [Automatic backup/migration, cold costs and qualification limits](../../reference/file-authority-state-log.md). +[Configuration checkpoints](../../reference/configuration-backup.md) additionally preserve machine defaults and source-owned Goal settings. Their isolated recovery does not adopt a live provider, restore Host bindings or close D1–D3; live configuration adoption and reviewed cutover remain with the existing owners. **Ownership simplification stage (2026-10-01).** R5/T4 separate storage promotion from policy migration. Fresh CLI promotion preserves policy; normal diff --git a/docs/reference/README.md b/docs/reference/README.md index d57d273e2a..6ccb66b23a 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -24,3 +24,5 @@ High-traffic read paths: read-only Goal acceptance gaps, pending gates, and historical progress read from `run_history.goals[].acceptance_observation`, and the Dashboard entry that renders them. Partial observations never certify acceptance. + +- [Configuration backup and recovery](configuration-backup.md): exact source-owned Goal and machine snapshots, isolated recovery and separate live adoption. diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md new file mode 100644 index 0000000000..fd94c0a5d5 --- /dev/null +++ b/docs/reference/configuration-backup.md @@ -0,0 +1,96 @@ +# Configuration backup and recovery + +`backup-state --execute` includes a first-class `configuration-backup.json` +component beside the physical archive. It captures the stored machine +configuration and complete source-owned Goal registry rows, including explicit +capability overrides, storage intent, extension fields, nulls and disabled +values. It resolves shared registry entries to their canonical project registry; +an effective/public settings projection cannot replace the stored configuration. +Unreadable or ambiguous source ownership aborts publication of the backup. +Capture compares the complete transport result with its source values; an +unrepresentable value (such as an integer rounded by JavaScript) aborts instead +of publishing a lossy checkpoint or replacing an earlier backup. + +Use a configuration-only backup without copying databases, Host sessions, +credential stores, skill directories or automation state: + +```sh +loopx --runtime-root /absolute/runtime --registry /absolute/registry.json \ + --format json configuration-backup export --goal-id example \ + --output /absolute/operator-owned/configuration.json +# Review scope, then repeat with --execute. Export never replaces an existing file. +loopx --runtime-root /absolute/runtime --registry /absolute/registry.json \ + --format json configuration-backup export --goal-id example \ + --output /absolute/operator-owned/configuration.json --execute +loopx --format json configuration-backup verify \ + --input /absolute/operator-owned/configuration.json +loopx --format json configuration-backup restore \ + --input /absolute/operator-owned/configuration.json \ + --expected-sha256 SHA256_FROM_VERIFY --destination /absolute/new-checkpoint +# Review, then repeat with --execute. +``` + +Omit `--goal-id` to include all Goals in the invoked registry; repeat it to +select several. Full `backup-state` captures its global discovery registry by +default, or the selected project registry with `--current-project-only`. The private +archive manifest reports configuration verification and presence separately. + +Settings → Capability Center → **Configuration backup and recovery** offers +download, file verification and isolated recovery through the same typed owner. +Device scope includes all invoked Goals and machine settings; Goal scope +includes the selected Goal plus machine settings. Recovery reports a relative +checkpoint reference under the runtime's `backups/configuration/`. A duplicate +restore rejects the occupied destination; it does not silently replace it. +CLI destinations require an existing physical parent and a new directory. +Symlink ancestors and occupied/dangling destinations are rejected. + +## Configuration is not activation + +The checkpoint contains the original envelope, the machine configuration at +`machine/configuration.json`, individual Goal rows under digest-named `goals/` +files, and a verified receipt. It creates no live registry, provider selector, +writer fence, lease, Host session, grant or timer. Unknown optional configuration +is preserved as data; its original installed owner must validate it before use. +Restoring these values is not proof that an optional provider is installed. + +Adopt reviewed machine namespaces through `machine-config preview/apply` +(or the existing settings editor), preserving destination siblings. Adopt Goal +overrides through `configure-goal` or the existing revision-checked Goal editor; +remap paths and identities and verify effective readback. These owners retain +their current revision, permission, global-sync and rollback contracts. This +checkpoint is not an alternate configuration authority or a batch-activation API. +Rollback live settings through their owning transactions; an unused isolated +checkpoint may be removed without changing live settings. + +In particular, three facts are different: a Markdown display file exists; +`goal_storage.new_goal_provider` requests SQLite for future Goals; and the live +Todo authority actually reads from `sqlite_v0`. Missing machine configuration +retains the existing File default. Applying a default after creation does not +retarget an existing Goal. Follow [new-Goal storage and provider selection](local-authority-provider-selection.md) +and [reviewed promotion](reviewed-coordination-promotion.md), then inspect the +real Todo `authority_read` after cutover. An isolated SQLite archive or a +configuration value alone cannot satisfy live provider acceptance. + +## Privacy, consistency and limits + +Backups are **private by default**. Full Goal rows can contain private paths, +organization data, identity bindings and provider-specific values. Credential +files are not opened by configuration capture, but credentials already embedded +in a configuration remain private data. `privacy_certified=false` never becomes +true because verification passed. A checksum proves content integrity, not +public-safety or authorization. Review and rebuild a separate portable artifact +before transferring it across trust boundaries; retain the private original. + +Capture reads each configuration owner independently, without a cross-project +atomicity promise. External files referenced by configuration are dependencies, +not inlined by this component; full state backup retains its existing discovery +scope. Inspect those dependencies and transfer/install reviewed contents +separately. Source-byte differences are not repaired by silently using a settings +summary or dropping unknown fields. + +The HTTP restore path uses the existing 64 MiB local-snapshot budget, without +changing the 64 kB ordinary request budget. Oversize or invalid envelopes reject +without partial recovery; CLI/effect transport keeps its existing bounds. +Restored files use owner-only permissions. POSIX checkpoint tests and the +packaged browser journey do not qualify native Windows execution, provider +promotion, a destination machine or long-duration SQLite operation. diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 1f6df697af..23066c0266 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; +import {configurationBackupScenario} from "./personal-workspace-browser/configuration-backup.mjs"; import {conversationImageRequestScenario} from "./personal-workspace-browser/conversation-image-request.mjs"; import {externalEvidenceReadbackScenario} from "./personal-workspace-browser/external-evidence-readback.mjs"; // Isolated browser acceptance scenarios for the personal Agent workspace. @@ -76,6 +77,7 @@ scenarioCatalog.push(performanceDiagnosisScenario); scenarioCatalog.push(blockedNoticeSettingsScenario); scenarioCatalog.push(nativeChildActivityScenario); scenarioCatalog.push(externalEvidenceReadbackScenario); +scenarioCatalog.push(configurationBackupScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/configuration-backup.mjs b/examples/personal-workspace-browser/configuration-backup.mjs new file mode 100644 index 0000000000..95865a301c --- /dev/null +++ b/examples/personal-workspace-browser/configuration-backup.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {mkdtemp, readFile, realpath, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import {repoRoot, outputDir} from "./fixture.mjs"; +import {openWorkspacePage} from "./scenario-context.mjs"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; + +// Only the backup routes use a real disposable server. Other workspace state +// uses the shared browser fixture; no Host, model or live runtime is contacted. +const serverCode = ` +import json,pathlib,sys,loopx +from loopx.chat_server import ChatHTTPServer,ChatRequestHandler +r=pathlib.Path(sys.argv[1]); runtime=r/'runtime'; registry=r/'registry.json' +registry.write_text(json.dumps({'goals':[{'id':'fixture','repo':str(r),'control_plane':{'optional':{'context':'complete '*10000}}}]})) +p=runtime/'machine/configuration.json';p.parent.mkdir(parents=True) +p.write_text(json.dumps({'schema_version':'loopx_machine_configuration_v0','namespaces':{'goal_storage':{'schema_version':'loopx_goal_storage_defaults_v0','new_goal_provider':'sqlite'}}})) +s=ChatHTTPServer(('127.0.0.1',0),ChatRequestHandler);s.registry_path=registry;s.runtime_root=runtime;s.verbose=False +print(json.dumps({'port':s.server_address[1],'python':sys.executable,'module_path':loopx.__file__}),flush=True);s.serve_forever() +`; + +export const configurationBackupScenario = { + id: "configuration-backup", + async run({browser, url}) { + const python = resolveTestPython({repoRoot}); + const explicitPython = ["LOOPX_TEST_PYTHON", "LOOPX_PYTHON_BIN", "LOOPX_PYTHON"].some(key => process.env[key]); + const env = {...process.env}; + if (!explicitPython) env.PYTHONPATH = repoRoot; + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-configuration-browser-"))); + const server = spawn(python, [...(explicitPython ? ["-I"] : []), "-u", "-c", serverCode, root], {cwd: repoRoot, env, stdio: ["ignore", "pipe", "pipe"]}); + let diagnostics = "", closed = false; + server.stderr.on("data", data => {diagnostics = (diagnostics + data).slice(-8000);}); + server.on("error", error => {diagnostics = (diagnostics + error.message).slice(-8000);}); + const closedPromise = new Promise(accept => server.once("close", () => {closed = true; accept();})); + let context; + try { + const backend = await new Promise((accept, reject) => { + let output = ""; + const cleanup = () => { + clearTimeout(timer); + server.stdout.off("data", onData); + server.off("error", onError); + server.off("exit", onExit); + }; + const fail = message => {cleanup(); reject(new Error(`${message}: ${diagnostics}`));}; + const onError = error => fail(`backup backend spawn failed: ${error.message}`); + const onExit = (code, signal) => fail(`backup backend exited ${code ?? signal}`); + const onData = data => { + output += data; + const newline = output.indexOf("\n"); + if (newline < 0) return; + try { + const ready = JSON.parse(output.slice(0, newline)); + assert.ok(Number.isInteger(ready.port) && ready.port > 0 && ready.port <= 65535); + assert.equal(ready.python, python, "backup backend must use the selected interpreter"); + assert.equal(typeof ready.module_path, "string"); + cleanup(); accept(ready); + } catch (error) {fail(`invalid backup backend readiness: ${error.message}`);} + }; + const timer = setTimeout(() => fail("backup backend did not start within 30 seconds"), 30000); + server.stdout.on("data", onData); + server.once("error", onError); + server.once("exit", onExit); + }); + const {port} = backend; + context = await openWorkspacePage(browser, url, {beforeGoto: async (_api, page) => { + await page.route("**/api/chat/configuration-backup/**", async route => { + const path = new URL(route.request().url()).pathname; + const response = await route.fetch({url: `http://127.0.0.1:${port}${path}`}); + await route.fulfill({response}); + }); + }}); + const {page} = context; + await page.getByRole("button", {name: "设置", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "能力中心", exact: true}).click(); + const panel = page.locator("details").filter({has: page.getByText("配置备份与恢复", {exact: true})}); + await panel.locator("summary").click(); + const downloaded = page.waitForEvent("download"); + await panel.getByRole("button", {name: "下载配置备份"}).click(); + const download = await downloaded, bytes = await readFile(await download.path()); + const backup = JSON.parse(bytes); + assert.equal(backup.data.machine_configuration.namespaces.goal_storage.new_goal_provider, "sqlite"); + assert.equal(backup.data.goals[0].goal_configuration.control_plane.optional.context.length, 90000); + const input = panel.getByLabel("恢复备份文件"); + await input.setInputFiles({name: "backup.json", mimeType: "application/json", buffer: bytes}); + await panel.getByRole("button", {name: "恢复为隔离检查点"}).click(); + await panel.getByText("检查点已恢复并核对;当前设置及存储 provider 未改变。", {exact: false}).waitFor(); + await page.screenshot({path: resolve(outputDir, "configuration-backup-restored.png"), animations: "disabled"}); + await input.setInputFiles({name: "bad.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({...backup, sha256: "changed"}))}); + await panel.getByRole("alert").waitFor(); + assert.equal(await panel.getByRole("button", {name: "恢复为隔离检查点"}).count(), 0); + await page.setViewportSize({width: 390, height: 844}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false); + return {coverageEntries: context.coverageEntries, errors: context.errors, backend, verified: "Packaged download, full real-backend checkpoint recovery, damaged-file rejection and narrow viewport"}; + } finally { + try {await context?.close();} finally { + if (!closed) { + server.kill("SIGTERM"); + const timer = setTimeout(() => server.kill("SIGKILL"), 5000); + try {await closedPromise;} finally {clearTimeout(timer);} + } + await rm(root, {recursive: true, force: true}); + } + } + }, +}; diff --git a/loopx/chat_configuration_api.py b/loopx/chat_configuration_api.py index 8d09835d63..fd566c59c3 100644 --- a/loopx/chat_configuration_api.py +++ b/loopx/chat_configuration_api.py @@ -8,6 +8,7 @@ from . import chat_machine_configuration_api as machine_api from . import chat_operator_provider_api as operator_api from . import chat_automation_cadence_api as cadence_api +from . import chat_configuration_backup_api as backup_api class ChatConfigurationRequestMixin( @@ -17,6 +18,7 @@ class ChatConfigurationRequestMixin( goal_api.GoalConfigurationRequestMixin, machine_api.MachineConfigurationRequestMixin, operator_api.OperatorProviderRequestMixin, + backup_api.ConfigurationBackupRequestMixin, ): """Expose machine and Goal configuration through one route registry.""" @@ -32,6 +34,8 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: def _configuration_post_routes(self) -> dict[str, Callable[[], None]]: return { + f"{backup_api.CONFIGURATION_BACKUP_PATH}/export": self._configuration_backup_export, + f"{backup_api.CONFIGURATION_BACKUP_PATH}/restore": self._configuration_backup_restore, f"{ownership_api.CHAT_GOAL_OWNERSHIP_PATH}/preview": lambda: self._ownership_update(execute=False), f"{ownership_api.CHAT_GOAL_OWNERSHIP_PATH}/apply": lambda: self._ownership_update(execute=True), cadence_api.CHAT_AUTOMATION_CADENCE_PREVIEW_PATH: lambda: self._cadence_update(execute=False), diff --git a/loopx/chat_configuration_backup_api.py b/loopx/chat_configuration_backup_api.py new file mode 100644 index 0000000000..1edbf361a2 --- /dev/null +++ b/loopx/chat_configuration_backup_api.py @@ -0,0 +1,51 @@ +"""Owner-local configuration download and isolated recovery; never activation.""" +from .configuration_backup import capture_configuration_backup, restore_configuration_backup, verify_configuration_backup +from .control_plane.effect_runtime import MAX_LOCAL_SNAPSHOT_BYTES + +CONFIGURATION_BACKUP_PATH = "/api/chat/configuration-backup" + + +class ConfigurationBackupRequestMixin: + def _configuration_backup_export(self): + try: + body = self._read_json() + if set(body) - {"goal_ids"}: + raise ValueError("configuration backup export contains unknown fields") + ids = body.get("goal_ids") + if ids is not None and (not isinstance(ids, list) or any(not isinstance(value, str) or not value.strip() for value in ids)): + raise ValueError("goal_ids must be a list of nonempty ids") + backup = capture_configuration_backup(registry_path=self.server.registry_path, + runtime_root=self.server.runtime_root, goal_ids=ids) + self._send_json({"ok": True, "status": "exported", "backup": backup, **verify_configuration_backup(backup)}) + except Exception: + self._send_error("Configuration backup could not be captured; inspect the source configuration.", + status=400, error_code="configuration_backup_capture_failed") + + def _configuration_backup_restore(self): + try: + # A configuration checkpoint uses the existing local-snapshot byte + # budget. Ordinary chat/configuration request limits stay unchanged. + body = self._read_json(max_bytes=MAX_LOCAL_SNAPSHOT_BYTES) + if set(body) != {"backup", "expected_sha256", "execute"} or not isinstance(body["execute"], bool): + raise ValueError("configuration backup restore request is invalid") + backup = body["backup"] + verified = verify_configuration_backup(backup) + if body["expected_sha256"] != verified["sha256"]: + raise ValueError("reviewed configuration digest changed") + # The browser cannot choose an arbitrary filesystem destination. + parent = self.server.runtime_root / "backups" / "configuration" + if parent.resolve() != parent.absolute(): + raise ValueError("configuration checkpoint parent cannot follow a symlink") + if body["execute"]: + parent.mkdir(parents=True, exist_ok=True, mode=0o700) + # A stable id makes a duplicate click an occupied-target conflict. + destination = parent / str(verified["sha256"]) + if not body["execute"]: + self._send_json({**verified, "status": "preview", "written": False}) + return + receipt = restore_configuration_backup(backup, destination=destination, + expected_sha256=body["expected_sha256"], execute=True) + self._send_json({**receipt, "checkpoint_ref": f"backups/configuration/{verified['sha256']}"}) + except Exception: + self._send_error("Configuration checkpoint could not be restored. Check its digest and whether this checkpoint already exists. Live settings were not changed.", + status=400, error_code="configuration_backup_restore_failed") diff --git a/loopx/cli_commands/configuration_backup.py b/loopx/cli_commands/configuration_backup.py new file mode 100644 index 0000000000..b2d83505e4 --- /dev/null +++ b/loopx/cli_commands/configuration_backup.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +from ..configuration_backup import capture_configuration_backup, restore_configuration_backup, verify_configuration_backup +from ..history import load_registry +from ..paths import resolve_runtime_root + + +def register_configuration_backup(subparsers, add_format): + parser = subparsers.add_parser("configuration-backup", help="Back up machine and source-owned Goal configuration; restore into an isolated checkpoint.") + commands = parser.add_subparsers(dest="configuration_backup_command", required=True) + export = commands.add_parser("export") + add_format(export) + export.add_argument("--goal-id", action="append", help="Select Goal ids; omit to capture all Goals in the invoked registry.") + export.add_argument("--output", required=True) + export.add_argument("--execute", action="store_true") + for name in ("verify", "restore"): + command = commands.add_parser(name) + add_format(command) + command.add_argument("--input", required=True) + if name == "restore": + command.add_argument("--destination", required=True) + command.add_argument("--expected-sha256", required=True) + command.add_argument("--execute", action="store_true") + + +def render_configuration_backup(payload): + return "\n".join(["# Configuration Backup", ""] + [f"- {key}: `{payload[key]}`" for key in + ("ok", "status", "sha256", "goal_count", "machine_configuration_present", "written", "readback_verified", "activation_performed", "error") if key in payload]) + "\n" + + +def handle_configuration_backup(args, *, registry_path, print_payload, output_format): + try: + if args.configuration_backup_command == "export": + runtime = resolve_runtime_root(load_registry(registry_path) if registry_path.exists() else {}, args.runtime_root, registry_path=registry_path) + backup = capture_configuration_backup(registry_path=registry_path, runtime_root=runtime, goal_ids=args.goal_id) + payload = {**verify_configuration_backup(backup), "status": "preview", "written": False} + if args.execute: + path = Path(args.output).expanduser() + # Keep backups immutable, including dangling symlink destinations. + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as stream: + staging = Path(stream.name) + try: + stream.write(json.dumps(backup, ensure_ascii=False, indent=2) + "\n") + stream.flush() + os.fsync(stream.fileno()) + # An exclusive hard-link publication cannot replace a + # destination created by another exporter. + os.link(staging, path) + finally: + staging.unlink(missing_ok=True) + if json.loads(path.read_text()) != backup: + raise RuntimeError("configuration backup export readback mismatch") + payload.update(status="exported", written=True) + else: + backup = json.loads(Path(args.input).expanduser().read_text(encoding="utf-8")) + payload = (verify_configuration_backup(backup) if args.configuration_backup_command == "verify" else + restore_configuration_backup(backup, destination=Path(args.destination).expanduser(), expected_sha256=args.expected_sha256, execute=args.execute)) + except Exception as exc: + payload = {"ok": False, "error": str(exc), "activation_performed": False} + print_payload(payload, output_format(args), render_configuration_backup) + return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index ceed0885d6..8559c5758a 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -81,6 +81,7 @@ AddFormat = Callable[[argparse.ArgumentParser], None] SUPPORT_CONTROL_COMMANDS = { + "configuration-backup", "automation-prompts", "backup-state", "chat", @@ -106,6 +107,8 @@ def register_support_control_commands( from .automation_prompts import register_automation_prompts register_automation_prompts(subparsers, add_subcommand_format) register_backup_state_command(subparsers, add_subcommand_format) + from .configuration_backup import register_configuration_backup + register_configuration_backup(subparsers, add_subcommand_format) register_heartbeat_control_commands(subparsers, add_subcommand_format) register_supervisor_control_commands(subparsers, add_subcommand_format) @@ -236,6 +239,11 @@ def handle_support_control_command( print_payload=print_payload, ) + if args.command == "configuration-backup": + from .configuration_backup import handle_configuration_backup + return handle_configuration_backup(args, registry_path=registry_path, + print_payload=print_payload, output_format=output_format) + if args.command == "backup-state": return handle_backup_state_command( args, diff --git a/loopx/cli_commands/support_control_backup.py b/loopx/cli_commands/support_control_backup.py index b3f295a374..f584e18456 100644 --- a/loopx/cli_commands/support_control_backup.py +++ b/loopx/cli_commands/support_control_backup.py @@ -90,6 +90,7 @@ def handle_backup_state_command( include_automations=not bool(args.no_automations), include_skills=not bool(args.no_skills), include_registry_projects=not bool(args.current_project_only), + registry_path=(Path(args.project).expanduser() / ".loopx/registry.json") if args.current_project_only else None, ) if args.execute: payload = execute_state_backup_plan(payload) diff --git a/loopx/configuration_backup.py b/loopx/configuration_backup.py new file mode 100644 index 0000000000..d8b5dca48e --- /dev/null +++ b/loopx/configuration_backup.py @@ -0,0 +1,44 @@ +"""Source-owner capture and CLI/HTTP transport for the TS configuration checkpoint.""" +from pathlib import Path +from typing import Any + +from .capabilities.machine_configuration.store import read_stored_machine_configuration +from .control_plane.effect_runtime import effect_runtime_result +from .control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from .history import load_registry +from .registry import registry_goals + + +def capture_configuration_backup( + *, registry_path: Path, runtime_root: Path, goal_ids: list[str] | None = None, +) -> dict[str, Any]: + registry = load_registry(registry_path) if registry_path.exists() else {"goals": []} + selected = goal_ids if goal_ids is not None else [str(goal["id"]) for goal in registry_goals(registry)] + if len(selected) != len(set(selected)): + raise ValueError("configuration backup Goal ids must be unique") + snapshots = [] + for goal_id in selected: + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + source = Path(route["source_registry"]) + matches = [goal for goal in registry_goals(load_registry(source)) if goal.get("id") == goal_id] + if len(matches) != 1: + raise ValueError("configuration backup requires exactly one source-owned Goal") + snapshots.append({"goal_id": goal_id, "goal_configuration": matches[0]}) + data = { + "machine_configuration": read_stored_machine_configuration(runtime_root), "goals": snapshots, + } + backup = effect_runtime_result("configuration.backup", {"action": "capture", "data": data}) + if backup.get("data") != data: + raise ValueError("configuration transport cannot preserve the complete source values") + return backup + + +def verify_configuration_backup(backup: dict[str, Any]) -> dict[str, Any]: + return effect_runtime_result("configuration.backup", {"action": "verify", "backup": backup}) + + +def restore_configuration_backup( + backup: dict[str, Any], *, destination: Path, expected_sha256: str, execute: bool = False, +) -> dict[str, Any]: + return effect_runtime_result("configuration.backup", {"action": "restore", "backup": backup, + "destination": str(destination.absolute()), "expected_sha256": expected_sha256, "execute": execute}) diff --git a/loopx/control_plane/configuration_backup.ts b/loopx/control_plane/configuration_backup.ts new file mode 100644 index 0000000000..444707e3e5 --- /dev/null +++ b/loopx/control_plane/configuration_backup.ts @@ -0,0 +1,104 @@ +/** Configuration checkpoint IO. Configuration owners retain validation and activation. */ +import {lstat, mkdir, mkdtemp, readFile, rename, rm, rmdir} from "node:fs/promises"; +import {dirname, isAbsolute, join, resolve} from "node:path"; +import type {JsonObject} from "./effect_program.ts"; +import {requireJsonObject, requireNonEmptyString} from "./runtime_decode.ts"; +import {canonicalAuthorityJson, canonicalAuthoritySha256, hasExactAuthorityKeys} from "./coordination/authority_store_codec.ts"; +import {durableWriteJson} from "./effect_runtime_io.ts"; + +const SCHEMA = "loopx_configuration_backup_v0"; + +function content(value: unknown): JsonObject { + const data = requireJsonObject(canonicalAuthorityJson(value), "configuration backup data"); + if (!hasExactAuthorityKeys(data, ["machine_configuration", "goals"])) throw new Error("configuration backup data fields are invalid"); + if (data.machine_configuration !== null) requireJsonObject(data.machine_configuration, "machine configuration"); + if (!Array.isArray(data.goals)) throw new Error("configuration backup goals must be an array"); + const identities = new Set(); + for (const item of data.goals) { + const entry = requireJsonObject(item, "configuration backup Goal"); + if (!hasExactAuthorityKeys(entry, ["goal_id", "goal_configuration"])) throw new Error("configuration backup Goal fields are invalid"); + const id = requireNonEmptyString(entry.goal_id, "configuration backup Goal id"); + const goal = requireJsonObject(entry.goal_configuration, "Goal configuration"); + if (identities.has(id) || goal.id !== id) throw new Error("configuration backup Goal identity is ambiguous"); + identities.add(id); + } + return data; +} + +export function captureConfigurationBackup(value: unknown): JsonObject { + const request = requireJsonObject(value, "configuration backup capture"); + const body: JsonObject = {schema_version: SCHEMA, privacy_certified: false, + activation_performed: false, data: content(request.data)}; + return {...body, sha256: canonicalAuthoritySha256(body)}; +} + +export function verifyConfigurationBackup(value: unknown): JsonObject { + const backup = requireJsonObject(canonicalAuthorityJson(value), "configuration backup"); + if (!hasExactAuthorityKeys(backup, ["schema_version", "privacy_certified", "activation_performed", "data", "sha256"]) + || backup.schema_version !== SCHEMA || backup.privacy_certified !== false || backup.activation_performed !== false) { + throw new Error("configuration backup envelope is invalid"); + } + const data = content(backup.data); + const {sha256, ...body} = backup; + if (sha256 !== canonicalAuthoritySha256(body)) throw new Error("configuration backup digest mismatch"); + return {ok: true, schema_version: SCHEMA, sha256, goal_count: (data.goals as unknown[]).length, + machine_configuration_present: data.machine_configuration !== null, + privacy_certified: false, activation_performed: false, readback_verified: true}; +} + +async function requirePhysicalParent(path: string): Promise { + // Reject pre-existing symlink ancestors; do not silently follow another runtime. + for (let current = path; ; current = dirname(current)) { + const stat = await lstat(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("configuration restore parent must be a physical directory"); + if (dirname(current) === current) break; + } +} + +export async function restoreConfigurationBackup(value: unknown): Promise { + const request = requireJsonObject(value, "configuration backup restore"); + const backup = requireJsonObject(request.backup, "configuration backup"); + const verification = verifyConfigurationBackup(backup); + if (request.expected_sha256 !== verification.sha256) throw new Error("reviewed configuration backup digest changed"); + const destination = requireNonEmptyString(request.destination, "configuration restore destination"); + if (!isAbsolute(destination) || resolve(destination) !== destination) throw new Error("configuration restore requires a normalized absolute destination"); + const parent = dirname(destination); + await requirePhysicalParent(parent); + try { await lstat(destination); throw new Error("configuration restore destination already exists"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (request.execute !== true && request.execute !== false) throw new Error("configuration restore execute must be a boolean"); + if (!request.execute) return {...verification, status: "preview", written: false}; + const staging = await mkdtemp(join(parent, ".loopx-configuration-")); + try { + const data = content(backup.data); + const files: Array<[string, unknown]> = [["configuration-backup.json", backup]]; + if (data.machine_configuration !== null) files.push(["machine/configuration.json", data.machine_configuration]); + for (const raw of data.goals as JsonObject[]) { + files.push([`goals/${canonicalAuthoritySha256(raw.goal_id)}.json`, raw.goal_configuration]); + } + for (const [name, payload] of files) { + const path = join(staging, name); + await mkdir(dirname(path), {recursive: true, mode: 0o700}); + await durableWriteJson(path, requireJsonObject(payload, "configuration checkpoint file")); + if (canonicalAuthoritySha256(JSON.parse(await readFile(path, "utf8"))) !== canonicalAuthoritySha256(payload)) { + throw new Error("configuration restore readback mismatch"); + } + } + const receipt = {...verification, status: "restored", written: true, + live_configuration_changed: false, configuration_files: files.map(([name]) => name)}; + await durableWriteJson(join(staging, "restore-receipt.json"), receipt); + // Exclusive reservation prevents a competing restore from being overwritten. + await mkdir(destination, {mode: 0o700}); + try { await rename(staging, destination); } + catch (error) { await rmdir(destination); throw error; } + return receipt; + } finally { await rm(staging, {recursive: true, force: true}); } +} + +export async function configurationBackupOperation(value: unknown): Promise { + const request = requireJsonObject(value, "configuration backup operation"); + if (request.action === "capture") return captureConfigurationBackup(request); + if (request.action === "verify") return verifyConfigurationBackup(request.backup); + if (request.action === "restore") return restoreConfigurationBackup(request); + throw new Error("unsupported configuration backup operation"); +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 2a4d441307..b75b0eaaa3 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -400,6 +400,7 @@ export function createEffectRuntimeHandlers( ["coordination.runtime_shadow.rollback", lazyHandler(() => Promise.all([import("./coordination/runtime_shadow.ts"), import("./coordination/source_transfer.ts")]), ([{rollbackCoordinationRuntimeShadow}, {withCoordinationSourceTransfer}]) => withCoordinationSourceTransfer("coordination.runtime_shadow.rollback", rollbackCoordinationRuntimeShadow))], ["coordination.local_authority.promote", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({promoteLocalCoordinationAuthority}) => promoteLocalCoordinationAuthority)], ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], + ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["coordination.sqlite_backup.snapshot", lazyHandler(() => import("./coordination/sqlite_backup.ts"), ({snapshotSqliteBackup}) => snapshotSqliteBackup)], ["coordination.local_authority.new_goal_storage", lazyHandler(() => import("./coordination/local_authority_defaults.ts"), ({manageNewGoalStorage}) => manageNewGoalStorage)], ["coordination.local_authority.promotion_review", lazyHandler(() => Promise.all([import("./coordination/local_authority_runtime.ts"), import("./coordination/source_transfer.ts")]), ([{reviewLocalCoordinationAuthorityPromotion}, {withCoordinationSourceTransfer}]) => withCoordinationSourceTransfer("coordination.local_authority.promotion_review", reviewLocalCoordinationAuthorityPromotion))], diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 27d95ee802..2320436f32 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -589,6 +589,14 @@ "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/cli_commands/configuration_backup.py::.handle_configuration_backup::codec_read:load_registry#1", + "line": 39, + "column": 44, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/cli_commands/coordination_shadow.py::.handle_coordination_shadow_command::codec_read:load_project_registry#1", "line": 219, @@ -949,6 +957,22 @@ "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/configuration_backup.py::.capture_configuration_backup::codec_read:load_registry#1", + "line": 15, + "column": 16, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, + { + "site": "loopx/configuration_backup.py::.capture_configuration_backup::codec_read:load_registry#2", + "line": 23, + "column": 52, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/configure_goal.py::.configure_goal::codec_transaction:project_registry_transaction#1", "line": 519, diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 3f60b2bd39..b133441ccc 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -371,6 +371,7 @@ def build_state_backup_plan( include_automations: bool = True, include_skills: bool = True, include_registry_projects: bool = True, + registry_path: Path | None = None, ) -> dict[str, Any]: resolved_project = _resolved(Path(project)) resolved_runtime_root = _resolved(Path(runtime_root).expanduser() if runtime_root else select_default_runtime_root()) @@ -398,6 +399,9 @@ def build_state_backup_plan( "backup_id": resolved_backup_id, "project": str(resolved_project), "runtime_root": str(resolved_runtime_root), + "configuration_source_registry": str(registry_path or ( + resolved_runtime_root / "registry.global.json" if include_registry_projects + else resolved_project / ".loopx/registry.json")), "registry_discovery": registry_discovery, "codex_home": str(_codex_home()), "output_dir": str(resolved_output_dir), @@ -516,6 +520,16 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: source = Path(str(item.get("source_path") or "")).expanduser() archive_name = str(item.get("archive_path") or source.name) _add_path_to_tar(tar, source, archive_name, exclude_roots, staging, snapshots) + from .configuration_backup import capture_configuration_backup, verify_configuration_backup + configuration = capture_configuration_backup( + registry_path=Path(payload["configuration_source_registry"]), + runtime_root=Path(payload["runtime_root"]), + ) + configuration_bytes = json.dumps(configuration, ensure_ascii=False, indent=2).encode("utf-8") + info = tarfile.TarInfo("configuration-backup.json") + info.size, info.mode = len(configuration_bytes), 0o600 + tar.addfile(info, io.BytesIO(configuration_bytes)) + updated["execution"]["configuration_backup"] = verify_configuration_backup(configuration) updated["execution"]["sqlite_snapshots"] = [entry[1] for entry in snapshots.values()] manifest_bytes = json.dumps(updated, ensure_ascii=False, indent=2).encode("utf-8") info = tarfile.TarInfo("manifest.json") diff --git a/tests/control_plane_ts/configuration_backup.test.ts b/tests/control_plane_ts/configuration_backup.test.ts new file mode 100644 index 0000000000..6666901c53 --- /dev/null +++ b/tests/control_plane_ts/configuration_backup.test.ts @@ -0,0 +1,60 @@ +import {test} from "node:test"; +import assert from "node:assert/strict"; +import {mkdtemp, mkdir, readFile, realpath, rm, symlink} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {captureConfigurationBackup, restoreConfigurationBackup, verifyConfigurationBackup} from "../../loopx/control_plane/configuration_backup.ts"; + +function snapshot() { + return captureConfigurationBackup({data: {machine_configuration: {schema_version: "loopx_machine_configuration_v0", namespaces: { + goal_storage: {schema_version: "loopx_goal_storage_defaults_v0", new_goal_provider: "sqlite"}, + optional_provider: {schema_version: "provider_v1", nullable: null, enabled: false}, + }}, goals: [{goal_id: "fixture", goal_configuration: {id: "fixture", control_plane: { + optional_provider: {context: "完整配置".repeat(10000)}, + }, coordination: {storage_target: {provider: "sqlite"}}}}]}}); +} + +test("checkpoint preserves complete optional configuration and never certifies privacy or activation", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "configuration-backup-"))); + try { + const backup = snapshot(), destination = join(root, "restored"); + const request = {backup, destination, expected_sha256: backup.sha256, execute: false}; + assert.equal((await restoreConfigurationBackup(request)).written, false); + const receipt = await restoreConfigurationBackup({...request, execute: true}); + assert.equal(receipt.readback_verified, true); + assert.equal(receipt.live_configuration_changed, false); + assert.deepEqual(JSON.parse(await readFile(join(destination, "configuration-backup.json"), "utf8")), backup); + assert.deepEqual(JSON.parse(await readFile(join(destination, "machine/configuration.json"), "utf8")), + (backup.data as Record).machine_configuration); + await assert.rejects(restoreConfigurationBackup({...request, execute: true}), /already exists/); + } finally {await rm(root, {recursive: true, force: true});} +}); + +test("tampering, extra envelope fields, duplicate identities and foreign reviewed digest reject before restore", async () => { + const backup = snapshot(); + assert.throws(() => verifyConfigurationBackup({...backup, data: {machine_configuration: null, goals: []}}), /digest mismatch/); + assert.throws(() => verifyConfigurationBackup({...backup, privacy_certified: true}), /envelope/); + assert.throws(() => verifyConfigurationBackup({...backup, extra: true}), /envelope/); + const data = backup.data as Record; + const goals = data.goals as unknown[]; + assert.throws(() => captureConfigurationBackup({data: {...data, goals: [...goals, ...goals]}}), /identity/); + await assert.rejects(restoreConfigurationBackup({backup, expected_sha256: "changed"}), /digest changed/); +}); + +test("dangling targets and symlink ancestors cannot redirect recovery", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "configuration-backup-"))); + try { + const backup = snapshot(); + await mkdir(join(root, "physical")); + await symlink(join(root, "physical"), join(root, "alias"), "dir"); + await symlink(join(root, "missing"), join(root, "dangling")); + for (const destination of [join(root, "alias/new"), join(root, "dangling")]) { + await assert.rejects(restoreConfigurationBackup({backup, destination, expected_sha256: backup.sha256, execute: true})); + } + } finally {await rm(root, {recursive: true, force: true});} +}); + +test("absence remains absence rather than introducing a machine default", () => { + const backup = captureConfigurationBackup({data: {machine_configuration: null, goals: []}}); + assert.equal(verifyConfigurationBackup(backup).machine_configuration_present, false); +}); diff --git a/tests/control_plane_ts/test_python_runtime.test.ts b/tests/control_plane_ts/test_python_runtime.test.ts index 1806bade06..791f4d2e53 100644 --- a/tests/control_plane_ts/test_python_runtime.test.ts +++ b/tests/control_plane_ts/test_python_runtime.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; import test from "node:test"; @@ -55,14 +55,18 @@ test("a worktree venv wins over an unusable system python3", t => { t.skip("POSIX launcher precedence; Windows discovery runs in the native CI lane"); return; } - const directory = mkdtempSync(join(tmpdir(), "loopx-test-python-venv-")); + const directory = realpathSync(mkdtempSync(join(tmpdir(), "loopx-test-python-venv-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); mkdirSync(join(directory, "scripts")); - mkdirSync(join(directory, ".venv", "bin"), { recursive: true }); const fakeBin = join(directory, "bin"); mkdirSync(fakeBin); copyFileSync(join(root, "scripts", "loopx-python.sh"), join(directory, "scripts", "loopx-python.sh")); - symlinkSync(resolveTestPython(), join(directory, ".venv", "bin", "python")); + // A symlink without pyvenv.cfg may report the base executable on macOS; + // qualify actual environment precedence with a real disposable venv. + const environment = spawnSync(resolveTestPython(), ["-m", "venv", "--without-pip", join(directory, ".venv")], { + encoding: "utf8", timeout: 20_000, + }); + assert.equal(environment.status, 0, environment.stderr); const systemMarker = join(directory, "system-python-was-used"); const fakeSystem = join(fakeBin, "python3"); writeFileSync(fakeSystem, `#!/bin/sh\ntouch '${systemMarker}'\nexit 1\n`, { mode: 0o755 }); diff --git a/tests/test_configuration_backup.py b/tests/test_configuration_backup.py new file mode 100644 index 0000000000..998932ab64 --- /dev/null +++ b/tests/test_configuration_backup.py @@ -0,0 +1,193 @@ +import json +import os +import subprocess +import sys +import tarfile +import threading +import http.client +from pathlib import Path +import venv + +import pytest + +from loopx.configuration_backup import capture_configuration_backup, restore_configuration_backup +from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry +from loopx.capabilities.machine_configuration.store import read_machine_configuration +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.state_backup import build_state_backup_plan, execute_state_backup_plan +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + +@pytest.fixture +def environment(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + source = tmp_path / "project/.loopx/registry.json" + source.parent.mkdir(parents=True) + goal = {"id": "fixture", "repo": str(source.parent.parent), "coordination": { + "storage_target": {"schema_version": "loopx_new_goal_storage_target_v0", "provider": "sqlite"}}, + "control_plane": {"optional_provider": {"nullable": None, "text": "完整" * 20000}}} + source.write_text(json.dumps({"goals": [goal]})) + runtime = tmp_path / "runtime" + machine = runtime / "machine/configuration.json" + machine.parent.mkdir(parents=True) + machine.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v0", "new_goal_provider": "sqlite"}}})) + global_registry = runtime / "registry.global.json" + global_registry.write_text(json.dumps({"registry_role": "global-local", "goals": [{ + "id": "fixture", "source_registry": str(source), "repo": goal["repo"], "control_plane": {"stale": True}}]})) + yield source, runtime, global_registry, goal + restart_effect_runtime() + + +def cli(*args): + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", *map(str, args)], + env={**os.environ, "LOOPX_USAGE_PING": "0"}, capture_output=True, text=True, timeout=60) + return result.returncode, json.loads(result.stdout) + + +def test_source_owner_capture_and_real_machine_owner_readback(environment, tmp_path): + source, runtime, registry, goal = environment + before = source.read_bytes(), (runtime / "machine/configuration.json").read_bytes() + backup = capture_configuration_backup(registry_path=registry, runtime_root=runtime) + assert backup["data"]["goals"] == [{"goal_id": "fixture", "goal_configuration": goal}] + destination = tmp_path / "checkpoint" + receipt = restore_configuration_backup(backup, destination=destination, expected_sha256=backup["sha256"], execute=True) + assert receipt["readback_verified"] is True and receipt["activation_performed"] is False + assert read_machine_configuration(destination, registry=build_builtin_machine_configuration_registry()) == backup["data"]["machine_configuration"] + assert not (destination / "registry.global.json").exists() + assert before == (source.read_bytes(), (runtime / "machine/configuration.json").read_bytes()) + + +def test_cli_export_verify_restore_and_occupied_target(environment, tmp_path): + _, runtime, registry, _ = environment + output = tmp_path / "backup.json" + arguments = ("--runtime-root", runtime, "--registry", registry, "configuration-backup") + assert cli(*arguments, "export", "--output", output)[1]["written"] is False + assert not output.exists() + code, exported = cli(*arguments, "export", "--output", output, "--execute") + assert code == 0 and exported["goal_count"] == 1 + original = output.read_bytes() + assert cli(*arguments, "export", "--output", output, "--execute")[0] == 1 + assert output.read_bytes() == original and output.stat().st_mode & 0o777 == 0o600 + assert cli(*arguments, "verify", "--input", output)[0] == 0 + restore = (*arguments, "restore", "--input", output, "--expected-sha256", exported["sha256"], "--destination", tmp_path / "restored") + assert cli(*restore)[1]["written"] is False + assert cli(*restore, "--execute")[1]["readback_verified"] is True + assert cli(*restore, "--execute")[0] == 1 + + +def test_full_state_backup_has_first_class_configuration_component(environment, tmp_path): + source, runtime, registry, goal = environment + payload = execute_state_backup_plan(build_state_backup_plan(project=source.parent.parent, + runtime_root=runtime, output_dir=tmp_path / "backups", include_skills=False, include_automations=False)) + with tarfile.open(payload["archive_path"]) as archive: + backup = json.load(archive.extractfile("configuration-backup.json")) + assert backup["data"]["goals"][0]["goal_configuration"] == goal + assert payload["execution"]["configuration_backup"]["readback_verified"] is True + + +def test_current_project_cli_uses_selected_project_not_invoked_registry(environment, tmp_path): + source, runtime, registry, goal = environment + other = tmp_path / "other-project/.loopx/registry.json" + other.parent.mkdir(parents=True) + other.write_text(json.dumps({"goals": [{"id": "other", "repo": str(other.parent.parent), "extension": {"disabled": False}}]})) + code, payload = cli("--runtime-root", runtime, "--registry", registry, "backup-state", + "--project", other.parent.parent, "--output-dir", tmp_path / "project-backup", + "--current-project-only", "--no-skills", "--no-automations", "--execute") + assert code == 0, payload + with tarfile.open(payload["archive_path"]) as archive: + backup = json.load(archive.extractfile("configuration-backup.json")) + assert [entry["goal_id"] for entry in backup["data"]["goals"]] == ["other"] + assert backup["data"]["goals"][0]["goal_configuration"]["extension"] == {"disabled": False} + + +def test_lossy_source_values_abort_without_replacing_previous_backup(environment, tmp_path): + source, runtime, registry, goal = environment + goal["extension"] = {"large_integer": 9007199254740993} + source.write_text(json.dumps({"goals": [goal]})) + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "previous-backup", include_skills=False, include_automations=False) + from pathlib import Path + archive, manifest = Path(plan["archive_path"]), Path(plan["manifest_path"]) + archive.parent.mkdir() + archive.write_bytes(b"previous verified archive") + manifest.write_bytes(b"previous verified manifest") + with pytest.raises(ValueError, match="complete source values"): + execute_state_backup_plan(plan) + assert archive.read_bytes() == b"previous verified archive" + assert manifest.read_bytes() == b"previous verified manifest" + assert len(list(archive.parent.iterdir())) == 2 + + +def test_live_http_download_recovery_and_negative_digest(environment): + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + _, runtime, registry, _ = environment + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root, server.verbose = registry, runtime, False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def post(operation, body): + connection = http.client.HTTPConnection(*server.server_address, timeout=30) + try: + connection.request("POST", f"/api/chat/configuration-backup/{operation}", json.dumps(body), {"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + try: + status, exported = post("export", {}) + assert status == 200 and exported["goal_count"] == 1 + body = {"backup": exported["backup"], "expected_sha256": exported["sha256"], "execute": False} + status, response = post("restore", body) + assert status == 200, response + assert response["status"] == "preview" + assert not (runtime / "backups/configuration").exists() + assert post("restore", {**body, "execute": True})[1]["status"] == "restored" + assert post("restore", {**body, "execute": True})[0] == 400 + assert post("restore", {**body, "expected_sha256": "changed"})[0] == 400 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_browser_backup_rejects_selected_environment_without_loopx(tmp_path): + selected = tmp_path / "empty-environment" + venv.EnvBuilder(with_pip=False).create(selected) + python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + temporary = tmp_path / "servers" + temporary.mkdir() + result = subprocess.run( + ["node", "--input-type=module", "-e", """ +import assert from 'node:assert/strict'; +import {configurationBackupScenario} from './examples/personal-workspace-browser/configuration-backup.mjs'; +await assert.rejects(configurationBackupScenario.run({ + browser: {newPage() {throw new Error('unexpected UI: wrong interpreter started');}}, url: '' +}), /No module named ['"]loopx['"]/); +"""], + cwd=Path(__file__).resolve().parents[1], + env={**os.environ, "LOOPX_TEST_PYTHON": str(python), "LOOPX_PYTHON_BIN": str(python), + "TMPDIR": str(temporary), "TMP": str(temporary), "TEMP": str(temporary)}, + capture_output=True, text=True, timeout=45, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert not list(temporary.glob("loopx-configuration-browser-*")) + + +def test_browser_backup_reuses_selected_python_without_checkout_shadow(tmp_path): + shadow = tmp_path / "shadow" + shadow.mkdir() + (shadow / "loopx.py").write_text("raise RuntimeError('unexpected PYTHONPATH shadow')\n") + result = subprocess.run( + ["node", "--input-type=module", "-e", """ +import assert from 'node:assert/strict'; +import {configurationBackupScenario} from './examples/personal-workspace-browser/configuration-backup.mjs'; +await assert.rejects(configurationBackupScenario.run({ + browser: {newPage() {throw new Error('selected backend reached UI');}}, url: '' +}), /selected backend reached UI/); +"""], + cwd=Path(__file__).resolve().parents[1], + env={**os.environ, "LOOPX_TEST_PYTHON": sys.executable, "LOOPX_PYTHON_BIN": sys.executable, + "PYTHONPATH": str(shadow)}, capture_output=True, text=True, timeout=45, + ) + assert result.returncode == 0, result.stdout + result.stderr