From cd1d56458ecaa12af481976fa6b421fe2d1734ac Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:05:58 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(=E4=BA=A4=E4=BB=98=E7=89=A9=E7=90=86):?= =?UTF-8?q?=20=E6=8E=A8=E8=BF=9B=200.7.x=20Console=20=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E9=9D=A2=E5=B9=B6=E4=BF=9D=E7=95=99=E6=BA=90=E7=A0=81=20Bridge?= =?UTF-8?q?=20=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要快照投影到工作区详情与总览任务状态,写路径继续走 Card execute 门。 Agent Bridge 只留源码与测试,默认 wheel 仍不含 dyro-bridge / dyro-mcp。 --- .github/workflows/ci.yml | 1 + .github/workflows/pypi-publish.yml | 3 +- CHANGELOG.md | 59 +++ ...6-delivery-physics-and-capability-plane.md | 8 +- docs/adr/0007-agent-bridge-phase-0.md | 6 +- docs/architecture.md | 2 +- .../agent-bridge-operation-inventory.md | 20 +- .../agent-bridge-phase-0-acceptance.md | 5 +- docs/designs/agent-bridge-protocol.md | 7 +- docs/designs/delivery-physics.md | 10 +- docs/designs/local-web-console.md | 32 +- plans/delivery-physics-implementation.md | 12 +- plans/dyro-agent-bridge-phase-0.md | 63 ++- src/dyro/bridge/__init__.py | 50 ++ src/dyro/bridge/__main__.py | 29 + src/dyro/bridge/catalog.py | 185 +++++++ src/dyro/bridge/constants.py | 12 + src/dyro/bridge/identity.py | 41 ++ src/dyro/bridge/models.py | 58 ++ src/dyro/bridge/observations.py | 276 ++++++++++ src/dyro/bridge/parse.py | 225 ++++++++ src/dyro/bridge/plans.py | 213 ++++++++ src/dyro/bridge/redaction.py | 47 ++ src/dyro/bridge/schemas.py | 161 ++++++ src/dyro/bridge/skill/SKILL.md | 75 +++ src/dyro/bridge/skill/agents/openai.yaml | 4 + src/dyro/bridge/skill/manifest.json | 7 + src/dyro/bridge/transport.py | 497 ++++++++++++++++++ src/dyro/capability/__init__.py | 2 + src/dyro/capability/cards.py | 7 + src/dyro/cli.py | 48 +- src/dyro/config.py | 13 + src/dyro/console/_inspect_worker.py | 28 +- src/dyro/console/assets.py | 12 +- src/dyro/console/assets/app.js | 170 +++++- src/dyro/console/assets/index.html | 1 + src/dyro/console/assets/styles.css | 17 + src/dyro/console/inspection.py | 182 ++++++- src/dyro/console/overview.py | 81 ++- src/dyro/console/server.py | 7 +- src/dyro/continuation/budgets.py | 2 +- src/dyro/continuation/store.py | 137 ++++- src/dyro/continuation/supervision.py | 1 + src/dyro/proof/derive.py | 31 +- src/dyro/task_dispatch.py | 9 + src/dyro/tasks.py | 1 + tests/test_bridge_catalog.py | 96 ++++ tests/test_bridge_identity.py | 42 ++ tests/test_bridge_models.py | 53 ++ tests/test_bridge_observations.py | 139 +++++ tests/test_bridge_plans.py | 154 ++++++ tests/test_bridge_redaction.py | 21 + tests/test_bridge_resolution.py | 74 +++ tests/test_bridge_skill.py | 81 +++ tests/test_bridge_transport.py | 222 ++++++++ tests/test_bridge_zero_effects.py | 82 +++ tests/test_capability.py | 90 ++++ tests/test_config.py | 22 + tests/test_console_assets.py | 11 + tests/test_console_inspection.py | 190 +++++++ tests/test_console_overview.py | 52 ++ tests/test_console_server.py | 3 +- tests/test_continuation_budget_preview.py | 206 ++++++++ tests/test_continuation_budgets.py | 1 + tests/test_continuation_supervision.py | 20 + tests/test_peer_wave.py | 2 + tests/test_proof_decay.py | 18 +- tests/test_proof_derive.py | 15 + tests/test_release_gates.py | 35 +- tests/test_release_source.py | 10 + tools/verify_bundle_stranger.py | 2 + tools/verify_release_gates.py | 81 ++- 72 files changed, 4420 insertions(+), 159 deletions(-) create mode 100644 src/dyro/bridge/__init__.py create mode 100644 src/dyro/bridge/__main__.py create mode 100644 src/dyro/bridge/catalog.py create mode 100644 src/dyro/bridge/constants.py create mode 100644 src/dyro/bridge/identity.py create mode 100644 src/dyro/bridge/models.py create mode 100644 src/dyro/bridge/observations.py create mode 100644 src/dyro/bridge/parse.py create mode 100644 src/dyro/bridge/plans.py create mode 100644 src/dyro/bridge/redaction.py create mode 100644 src/dyro/bridge/schemas.py create mode 100644 src/dyro/bridge/skill/SKILL.md create mode 100644 src/dyro/bridge/skill/agents/openai.yaml create mode 100644 src/dyro/bridge/skill/manifest.json create mode 100644 src/dyro/bridge/transport.py create mode 100644 tests/test_bridge_catalog.py create mode 100644 tests/test_bridge_identity.py create mode 100644 tests/test_bridge_models.py create mode 100644 tests/test_bridge_observations.py create mode 100644 tests/test_bridge_plans.py create mode 100644 tests/test_bridge_redaction.py create mode 100644 tests/test_bridge_resolution.py create mode 100644 tests/test_bridge_skill.py create mode 100644 tests/test_bridge_transport.py create mode 100644 tests/test_bridge_zero_effects.py create mode 100644 tests/test_continuation_budget_preview.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3af0b7..85b34a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,7 @@ jobs: test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" test ! -e "$smoke_root/wheel-venv/bin/dyro-mcp" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/dispatch-doctor.json" + "$smoke_root/wheel-venv/bin/python" "$GITHUB_WORKSPACE/tools/verify_bundle_stranger.py" "$smoke_root/wheel-venv/bin/dyro" uv run python -m venv "$smoke_root/sdist-venv" sdist_artifact="$(find /tmp/dyro-dist -maxdepth 1 -name 'dyro-*.tar.gz' -print -quit)" "$smoke_root/sdist-venv/bin/pip" install "${sdist_artifact}" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index fafba4c..4eabcc4 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -105,7 +105,7 @@ jobs: raise SystemExit(f"release tag {actual!r} must equal {expected!r}") PY - - name: Refuse 1.0.0 without delivery-physics gates + - name: Refuse a physics-train release without 0.7.x gates env: RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} run: uv run python tools/verify_release_gates.py --release-tag "$RELEASE_TAG" @@ -156,6 +156,7 @@ jobs: test ! -e "$smoke_root/wheel-venv/bin/dyro-mcp" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/wheel-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/wheel-doctor.json'))" + "$smoke_root/wheel-venv/bin/python" "$GITHUB_WORKSPACE/tools/verify_bundle_stranger.py" "$smoke_root/wheel-venv/bin/dyro" uv run python -m venv "$smoke_root/sdist-venv" "$smoke_root/sdist-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -c "import experiments.local_agent_dispatch" diff --git a/CHANGELOG.md b/CHANGELOG.md index aff3b93..8798f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ ## Unreleased +- Console workspace detail now projects already-captured lines, tasks, and + objectives from the same summary snapshot. Overview polling stays + count-only. Summary Proof and task integration stay `not_inspected`; + `PROOF_DECAYED` stays on the independent inspect. +- Console overview now rolls up `task_status_counts` from readable + workspaces only. Unreadable workspaces stay unknown and are not counted + as zero. +- Console meta advertises `proofs`. The shell fetches + `GET /api/v1/workspaces/{alias}/proofs` only when that capability is present. +- Overview summary cards now carry `proof_inspection=not_inspected`. Isolated + inspection rejects an `inspected` summary so the two Proof entries cannot + collapse. +- Independent Proof inspect stays in the exec worker so a hung git is reaped + by the parent's process-group kill. Nested spawn is withdrawn: a successful + worker exit must not leave inspect descendants. Timeout reports unread, not + inspected. +- Console labels Proof kinds and statuses in Chinese. Overview cards no + longer treat unread summary as workspace Proof state. `live` is kind-specific + and is not merge clearance. +- `0.7.x` release gates ignore comment-only markers, refuse `0.8` / `0.9` + feature numbers, and keep a later `1.0.0` identity tag on the same stranger + contract. Wheel smoke now runs the stranger bundle check. +- Production `BudgetUsage` / `BudgetRequest` read `trusted_usage` from the + executor Card. The default is untrusted. Proof still does not feed + no-progress exhaustion. +- `proof list --line` includes `trigger_observation` from Objectives on that + line and stays mutually exclusive with `--task` / `--objective`. `--task` + still excludes triggers. +- Console inspection fails closed on Windows without starting a worker. +- `objective tick` and `objective plan` preview `decide_budget` for the + selected wave. Automatic Objectives pass `automatic=True`; supervised + apply stays `automatic=False`. Untrusted usage hard-stops only when + `workspace.max_provider_usage` is set. Preview never reserves. +- `run_task_bound_dispatch` now requires the Capability plane on write and + refuses a Card without `execute`. No Card remains the 0.6.9 second door. +- Console meta advertises `surfaces` (and keeps `capabilities` as an alias) + so HTTP feature flags are not Capability Cards. +- Agent Bridge S1 contracts live in source as `dyro.bridge`. The default + wheel still omits that package and does not grow `dyro-bridge` / + `dyro-mcp` scripts. +- Agent Bridge S2/S3 source adapters resolve workspaces, emit path-free + observations, and return non-executable Objective plans. Catalog marks + those IDs `implemented_testable`. Authoritative Git observations stay + unavailable. No transport entry point and no default-wheel package. +- Agent Bridge S4 source transport parses one bounded JSON request and + emits one redacted response. Public exposure stays empty: hello and + plans are `implemented_testable` only. No `dyro-bridge` script. +- Agent Bridge S5 source catalog promotes the seven Mandatory operations + to `public_available` only on Linux. macOS/Windows public exposure + stays empty. In-process zero-effect traps cover hello. Still no + console script, wheel package, or CI gate. +- Agent Bridge S6 adds a source-only Skill and `python -m dyro.bridge` + public process. The Skill is not an integration asset and is not + installable. Default wheel and `dyro-bridge` / `dyro-mcp` scripts stay + absent. +- Agent Bridge S7 / `dyro-mcp` stays out. Design status lines now match + the `0.6.3` removal: published artifacts remain CLI + Skill, not Bridge + or MCP. + ## 0.7.1 - 2026-08-16 - Console opens an independent Proof inspect at diff --git a/docs/adr/0006-delivery-physics-and-capability-plane.md b/docs/adr/0006-delivery-physics-and-capability-plane.md index 5defa3a..563ba3d 100644 --- a/docs/adr/0006-delivery-physics-and-capability-plane.md +++ b/docs/adr/0006-delivery-physics-and-capability-plane.md @@ -26,7 +26,7 @@ 1. Dyro 的产品身份锁定为 **本地优先的多仓交付物理引擎**,不是 agent、不是舰队、不是 skill 超市。 2. 抽出 **Proof Object** 作为已验证事实的统一投影。它不取代 `task.toml`、receipt、review 绑定或 Continuation journal。 -3. 每个 Proof 带 **衰减函数**。substrate 变化后事实死亡;不确定不得写成通过。`decay(review_verdict)` 全量等于 `_valid_review_acceptance`;`decay(signoff)` 全量等于 `_valid_external_signoff`。`SchedulerSnapshot` 只把 merge 相关的 `live` Proof 投影进已有进展字段,不计入 trigger;journal 不把 proofs 当 PASS。生产 `BudgetUsage` 在 `0.7` 不因 Proof 新开 no-progress 耗尽。 +3. 每个 Proof 带 **衰减函数**。substrate 变化后事实死亡;不确定不得写成通过。`decay(review_verdict)` 全量等于 `_valid_review_acceptance`;`decay(signoff)` 全量等于 `_valid_external_signoff`。`SchedulerSnapshot` 只把 merge 相关的 `live` Proof 投影进已有进展字段,不计入 trigger;journal 不把 proofs 当 PASS。生产 `BudgetUsage` 不因 Proof 新开 no-progress 耗尽。`provider_usage_trusted` 只来自 Capability Card,默认 `false`。 4. 用 **Capability Card** 统一 agent / gate / reviewer / trigger / tool。`0.7.0` 解析 `[[capabilities]]` 并升级 `[adapters.*]`,缺省 `cannot_prove` 至少包含 `done` 与 `merge`。 5. 增加 **Host Compiler**:把定律与本机可用 Card 编译为宿主投影(`SKILL.md` 与可选拦截文件)。编译器只收缩权威,不扩大权威。 6. 所有 mutation 落入操作格 `observe | execute | review | sign | integrate | publish`。有效权威仍是策略 ∩ 合约 ∩ 租约 ∩ 任务权限 ∩ 图约束。 @@ -35,7 +35,7 @@ 9. **`0.7` 衰减锁定为 A1**:对 merge / 下游释放的接受与拒绝,必须与 `0.6.0` 现有绑定检查同真值。Proof 只提供投影与 `PROOF_DECAYED` reason code,不是第二套门。`merge_task` / `check_dispatchable` 不读 Proof store。下游只投影 `_assert_dependency_integrated`;decayed review 不加严 ready set。任务仓 dirty:`0.6` 已拒绝,`0.7` 保持拒绝,不放松、不叠门。开发线 dirty / 错分支保持 `_prepare_merge` 现有错,不得标成 `PROOF_DECAYED`。不把 `git revert` 当成祖先断裂。 10. **可携带核验锁定为 B1**:`verify-bundle` 核验完整性,不核验身份,也不承诺与当前工作区 `proof verify` / `task merge` 同一套 `live` / `decayed`。输入是 Proof Bundle + 调用方提供的 git 对象。捆内不塞 git 对象库。缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`,不得写成 `live`。无 `--current-heads` 时不得报与 merge 相同的衰减结论。该能力在 `0.7.x` 发布,不另开 `1.0.0` 功能号。 11. **写路径两扇门**:有 Card 时,argv adapter、`run_task_bound_dispatch` 与 Peer Wave 写绑定必须同受 `execute` 门。无 Card 的 dispatch 就绪是 0.6.9 已存在的第二扇门(显式允许),不是已审计 Card。PATH / 发现不是 Card。不得同时声称「PATH 发现不能执行」与「dispatch 就绪即可写」。 -12. **版本列车收口为 `0.7.x`**:交付物理功能全部在 `0.7.x` 发布。取消 `0.8.0` / `0.9.0` 功能列车。`1.0.0` 只是身份冻结,未显式要求不得打。 +12. **版本号停在 `0.7.x`,功能列车继续**:后续功能和发版继续往前,号写成 `0.7.2`、`0.7.3`……。不另开 `0.8.0` / `0.9.0` / `1.0.0` 作为功能号。`1.0.0` 只是以后的身份冻结,不是下一列功能车。 ## 否决项 @@ -63,8 +63,8 @@ ## 后果 - 产品叙事从「启动 agent」转为「核验完成」。 -- `0.7.0` 落地 Proof、Capability Card、Host Compiler 与 `verify-bundle`。`trusted_usage` 只解析、默认 `false`,不接入生产 `BudgetUsage`。Console summary 保持 `proof_inspection=not_inspected`,不探 Git / Proof;`dyro objective attention` 走完整快照,可报 `PROOF_DECAYED`。两套入口不得写成同一套 Proof 展示。 -- 剩余功能(Console 独立 inspect、`trigger_observation`、陌生人核验与叙事锁)继续走 `0.7.x`,不另开 `0.8.0` / `0.9.0` / `1.0.0`。 +- `0.7.0` 落地 Proof、Capability Card、Host Compiler 与 `verify-bundle`。`trusted_usage` 默认 `false`;生产 `BudgetUsage` / `BudgetRequest` 从 Card 读取该字段。`objective tick` 对 automatic Objective 做预算预览(`automatic=True`),受监督 apply 仍 `automatic=False`。未信任用量只在存在 `workspace.max_provider_usage` 时硬停。Proof 仍不接入 `no_progress`。Console summary 保持 `proof_inspection=not_inspected`,不探 Git / Proof;`dyro objective attention` 走完整快照,可报 `PROOF_DECAYED`。两套入口不得写成同一套 Proof 展示。 +- Console 独立 inspect、`trigger_observation`、陌生人核验与叙事锁已在 `0.7.1` 落地。后续功能与产品面收口继续开发和上线,版本号保持 `0.7.x`。 - 可携带核验的对外承诺仍是:陌生人拿着 Proof Bundle 和自己提供的 git 对象,能得到与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析)。这不是身份证明,也不是「现在工作区还能 merge」。`schema_version = 1` 的合同在 `0.7.x` 锁住语义;冻结成 `1.0.0` 身份号须另做产品决定。 - 实施成本是新的投影层与兼容层,而不是第二套调度器。 diff --git a/docs/adr/0007-agent-bridge-phase-0.md b/docs/adr/0007-agent-bridge-phase-0.md index 141c60a..9e95d7d 100644 --- a/docs/adr/0007-agent-bridge-phase-0.md +++ b/docs/adr/0007-agent-bridge-phase-0.md @@ -2,7 +2,11 @@ ## Status -Proposed +Proposed. Source contracts through S6 live under `src/dyro/bridge/`. This +`0.7.x` Core tree keeps the published surface bridge-free: `dyro.bridge` is +not in `packages =`, and `dyro-bridge` / `dyro-mcp` are not console scripts. +Decision 10's "ship in the Dyro Python distribution" clause is the later +extra, not the current wheel. S7 MCP/Plugin is not started. ## Context diff --git a/docs/architecture.md b/docs/architecture.md index e661c45..75adbd2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -241,6 +241,6 @@ Dyro 的交付拓扑与之**实质相近**:TaskGraph(`depends_on` / conflict 未来的 adapter、通知、签名规则、发布平台与审批系统应使用 Python entry point 或独立 Profile 扩展包接入;不要把某个组织的策略加入 core 默认行为。 -`0.7.0` 把已有证据物理学抽成可复验的 Proof,并把 argv adapter 升级为 Capability Card,再把定律编译为只收缩权威的宿主投影。衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称。Console summary 与 `dyro objective attention` 不是同一套 Proof 展示。剩余交付物理功能(Console 独立 inspect、可携带核验门禁)继续在 `0.7.x` 发布,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 +`0.7.0` 把已有证据物理学抽成可复验的 Proof,并把 argv adapter 升级为 Capability Card,再把定律编译为只收缩权威的宿主投影。衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称。Console summary 与 `dyro objective attention` 不是同一套 Proof 展示。Console 独立 inspect、`trigger_observation` 与可携带核验门禁已在 `0.7.1` 落地;后续功能继续在 `0.7.x` 开发和上线,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 开发者侧的可选本地多 Agent 派发(五段式任务契约、注入前机密守卫、locator 核验、隔离 patch)与上述控制面分层并列,随 `dyro` 安装包分发(`dyro dispatch` / `import experiments.local_agent_dispatch`),但**不**替代 gates/合并。同时写多块走 Core Peer Wave(task worktree + `conflict_group`),见 [`peer-wave-execution.md`](designs/peer-wave-execution.md)、[`ADR-0002`](adr/0002-optional-local-agent-dispatch.md)、[`多智能体编排纪律`](agent-orchestration-discipline.md) 与 [`可选本地 Agent 派发设计`](designs/optional-local-agent-dispatch.md)。 diff --git a/docs/designs/agent-bridge-operation-inventory.md b/docs/designs/agent-bridge-operation-inventory.md index cdf3beb..6068090 100644 --- a/docs/designs/agent-bridge-operation-inventory.md +++ b/docs/designs/agent-bridge-operation-inventory.md @@ -1,6 +1,9 @@ # Dyro Agent Bridge Operation Inventory -Status: Linux Ubuntu 24.04 Mandatory Core Surface promoted at S5 +Status: `0.7.x` source catalog may mark the seven Mandatory IDs +`public_available` on Linux only. Published wheel/sdist do not include +`dyro.bridge` or `dyro-bridge`, so that promotion is not an installed +product surface. Decision source: [ADR 0007](../adr/0007-agent-bridge-phase-0.md) @@ -68,16 +71,17 @@ unavailable rather than being interpreted with incomplete config. Declared status is not implementation approval. Each row must acquire a source call graph and pass the acceptance matrix before it becomes public-available. -S5 promotes exactly the seven Mandatory Core Surface operations on Linux Ubuntu -24.04. The other five implemented services and all six declared services remain -unavailable through the installed transport. macOS 15 remains declared and -Windows unavailable, so neither host receives an implicit availability -override from this promotion. +S5 source catalogs may promote exactly the seven Mandatory Core Surface +operations on Linux. That is a source-tree availability bit, not an installed +`dyro-bridge` process. The other implemented services stay +`implemented_testable`. macOS and Windows keep an empty public surface. +The published `0.7.x` wheel does not ship this catalog. ### Mandatory Core Surface -Phase 0 cannot pass with an empty available catalog. The following non-empty -surface is mandatory in the source-tree, wheel, and sdist protocol corpus: +Phase 0 cannot later pass with an empty available catalog. The following +non-empty surface is the Mandatory Core set. In `0.7.x` it is a source +catalog on Linux only; wheel and sdist must not expose it: - `bridge.hello`; - `bridge.capabilities.compact`; diff --git a/docs/designs/agent-bridge-phase-0-acceptance.md b/docs/designs/agent-bridge-phase-0-acceptance.md index 8c4242d..fe744fb 100644 --- a/docs/designs/agent-bridge-phase-0-acceptance.md +++ b/docs/designs/agent-bridge-phase-0-acceptance.md @@ -1,6 +1,9 @@ # Dyro Agent Bridge Phase 0 Acceptance Matrix -Status: Enforced Linux source/wheel/sdist release gate +Status: Source-tree contracts only in `0.7.x`. The published wheel/sdist +gate is the opposite of a Bridge promotion: `dyro.bridge` must stay absent, +and `dyro-bridge` / `dyro-mcp` must not exist. Linux artifact/public-process +gates are not enforced on this train. Authority: [ADR 0007](../adr/0007-agent-bridge-phase-0.md) diff --git a/docs/designs/agent-bridge-protocol.md b/docs/designs/agent-bridge-protocol.md index 0ba311b..fd14073 100644 --- a/docs/designs/agent-bridge-protocol.md +++ b/docs/designs/agent-bridge-protocol.md @@ -18,7 +18,12 @@ changing a field, copying a digest, adding `--yes`, or claiming an actor. ## 2. Process contract -The packaged console entry point is: +The long-term packaged console entry point is `dyro-bridge`. In this `0.7.x` +train that script is **not shipped**. Source-tree callers may use +`python -m dyro.bridge`; an installed `dyro` wheel must not provide +`dyro-bridge` or `dyro-mcp`. + +The packaged name, when a later extra exists, remains: ```text dyro-bridge diff --git a/docs/designs/delivery-physics.md b/docs/designs/delivery-physics.md index aba2ebb..8154484 100644 --- a/docs/designs/delivery-physics.md +++ b/docs/designs/delivery-physics.md @@ -1,7 +1,7 @@ # Dyro 交付物理学与能力平面 -状态:提案;2026-08-15 锁定 A1 / B1;2026-08-16 版本列车收口为 `0.7.x` -目标版本:`0.7.0` 起在 `0.7.x` 内收口全部交付物理功能;不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。`1.0.0` 只是身份冻结,未显式要求不得打。 +状态:提案;2026-08-15 锁定 A1 / B1;2026-08-16 版本列车收口为 `0.7.x` +目标版本:`0.7.0` 起功能继续在 `0.7.x` 开发和上线;不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。`1.0.0` 只是以后的身份冻结,不是下一列功能车。 适用范围:Dyro Core、Profile、Host Compiler、Witness;不改写已发布的 TaskGraph / Objective / Console 权威语义 已锁定: @@ -237,7 +237,7 @@ decay(proof, current_substrate, clock) -> live | decayed | inconclusive 4. `trigger_observation`:`0.7.x` 已派生。用现有 `next_probe_at`,只影响唤醒,不影响完成,也不进入 `progress_fingerprint`。 5. 用户或策略显式撤销 → `revoked`。这不是 `decay()` 的返回值。 -planner 在构造 **`SchedulerSnapshot`** 时评估衰减(不是未使用的 `ContinuationSnapshot`)。`progress_fingerprint` 的纯函数契约继续忽略 trigger;该函数已锁,但生产 `_budget_usage` **尚未**接线 `decide_no_progress`。`0.7` 不把 Proof 接入生产 `BudgetUsage`,不新开 no-progress 自动耗尽。merge 相关 live Proof 若投影,只进已有 `effective_evidence` / `integration_heads`,不并排再加一层。 +planner 在构造 **`SchedulerSnapshot`** 时评估衰减(不是未使用的 `ContinuationSnapshot`)。`progress_fingerprint` 的纯函数契约继续忽略 trigger;该函数已锁,但生产 `_budget_usage` **尚未**接线 `decide_no_progress`。Proof 不接入生产 `BudgetUsage`,不新开 no-progress 自动耗尽。`trusted_usage` 接入 `BudgetUsage.provider_usage_trusted` 与 `BudgetRequest.provider_usage_trusted`,默认 `false`。受监督 apply 仍 `automatic=False`,因此未审计用量不会在人手确认路径上新开硬停。`objective tick` 对 `requested_mode = automatic` 的 wave 做 `decide_budget(..., automatic=True)` 只读预览,零写、不 reserve。工作区可选 `workspace.max_provider_usage`;没有这个 cap 时,未信任用量不会硬停。merge 相关 live Proof 若投影,只进已有 `effective_evidence` / `integration_heads`,不并排再加一层。 `PROOF_DECAYED` **仅当**对应 Proof 从 `live` → `decayed` 且该衰减挡住的是 **merge** 人话。不得用它命名线 dirty / 错分支 / push 失败,也不得用它 block 下游 ready set。状态字段本身仍不是放行证据。 @@ -258,7 +258,7 @@ read = ["codex", "exec", "--sandbox", "workspace-write", "{prompt}"] write = ["codex", "exec", "--sandbox", "workspace-write", "{prompt}"] attested_isolation = "cwd" # none | cwd | worktree | os_sandbox | external_runner -trusted_usage = false # 0.7 解析该字段;不接入生产 BudgetUsage +trusted_usage = false # 生产 BudgetUsage / BudgetRequest 读取;默认 false can_prove = [] # 只能填 Proof kind;空表示输出不能当完成证据 cannot_prove = ["done", "merge", "security", "product_acceptance"] intents = ["observe", "execute"] @@ -274,7 +274,7 @@ hosts = ["cli"] # cli = Dyro 启动的 adapter;不是宿 | `can_prove` | 它的输出里,哪些可以变成 Proof。只填 Proof kind,不填 dispatch 词汇。 | | `cannot_prove` | 即使它写了「已完成」,Core 也不得采信。 | | `intents` | 它可请求的操作格:`observe` `execute` `review` `sign` `integrate` `publish`。 | -| `trusted_usage` | 是否能返回可核验用量。0.7 解析并默认 `false`,不接入生产 `BudgetUsage` / 硬限额自动跑。 | +| `trusted_usage` | 是否能返回可核验用量。默认 `false`。生产 `BudgetUsage` / `BudgetRequest` 读取该字段;只有自动 tick 预览且存在 `workspace.max_provider_usage` 时,未信任用量才会硬停。人手 apply 不因此新开硬停。 | | `hosts` | 允许被编译到哪些宿主表面。 | 兼容:本 `0.7.0` 已解析 `[[capabilities]]`,并把 `[adapters.*]` 升级为 Card。缺省 `cannot_prove` 至少包含 `done` 与 `merge`,`attested_isolation = "cwd"`。`dyro agent add` 继续工作,内部写 Card。 diff --git a/docs/designs/local-web-console.md b/docs/designs/local-web-console.md index 96249d2..45dcce3 100644 --- a/docs/designs/local-web-console.md +++ b/docs/designs/local-web-console.md @@ -234,8 +234,16 @@ C04 将真实工作区读取移出 HTTP 请求线程,并补齐概览的单工 inspection fail closed,不会以“只终止 outer process”的方式留下读取子进程; - worker 到 listener 只返回有大小上限的规范 JSON。父进程重新校验 schema、digest、freshness 和 payload 形状;无效输出、超时或 worker 失败全部 fail closed 为稳定 code; -- `GET /api/v1/workspaces/{alias}` 需要 bearer,只接收单段安全 alias,复用同一 summary DTO 与 - ETag。未知 alias、编码 traversal 或双段路径不会落入 workspace 读取; +- `GET /api/v1/workspaces/{alias}` 需要 bearer,只接收单段安全 alias。响应复用同一 + summary DTO,并附带同一次 summary 快照里已经捕获的 `lines` / `tasks` / + `objectives`。这不是独立 inspect:摘要卡必须带 `proof_inspection=not_inspected`, + 任务 `integration_state` 必须是 `not_inspected`,不得带 `proofs`,也不得把 + `PROOF_DECAYED` 写进这张详情。未知 alias、编码 traversal 或双段路径不会落入 + workspace 读取; +- `GET /api/v1/workspaces/{alias}/proofs` 是独立 inspect,不改写 summary。摘要卡必须带 + `proof_inspection=not_inspected`;父进程若看到 `inspected` 会拒收该卡。inspect 与 + summary 共用同一 exec worker 与进程组回收;超时由父进程 `killpg`,只回报未检查, + 不把摘要标成已检查。不得在 worker 内再 spawn 一层后成功退出,留下 hung git; - overview 和 workspace 的 ETag 覆盖 data 以及 `freshness.state`、`partial` 和 warnings,排除仅 表示采样时刻的 `captured_at`,因此 warning-only 变化也会使条件请求重新获得 200。 @@ -269,8 +277,10 @@ C06 将已认证的总览接口接入无框架浏览器界面,优先让使用 - 可点击卡片查看当前 C04 summary,支持条件 ETag 刷新。页面在后台时暂停轮询,恢复可见时只恢复 单一轮询定时器;会话过期或本地读取失败显示一条恢复说明,不猜测数据或修改项目。 -C06 仅消费已发布的 overview/workspace 只读 DTO;Objective、Task 证据链、图和活动的深度 API -仍须在对应 Core 投影准备完成后通过单独的只读扩展交付,不能由浏览器自行推导。 +C06 仅消费已发布的 overview/workspace 只读 DTO。工作区详情可以展示同一次 +summary 快照里的线、任务和目标清单;overview 轮询不得带上这些列表。独立 Proof +inspect 只在 meta 声明 `proofs` 能力后由详情页按需请求,不得在 overview 轮询里打开。 +单条 line / Objective / Task 证据链、图和活动的深度 API 仍须在对应 Core 投影准备完成后通过单独的只读扩展交付,不能由浏览器自行推导。 ## 5. 模块设计 @@ -372,11 +382,11 @@ argv、环境值和原始解析内容不得直接进入 `facts` 或 recovery com `ConsoleOverview`: -- Dyro 版本和 API capabilities; +- Dyro 版本和 API surfaces(`overview` / `proofs`;`capabilities` 仍是兼容别名,不是 Capability Card); - registry 状态、默认或当前别名; - `WorkspaceSummary[]`; -- 全局 attention 计数和最高优先级事项; -- 缓存更新状态。 +- 全局 attention 计数、可读工作区的 Task 状态分布,以及最高优先级事项; +- 缓存更新状态。不可读工作区的任务数不得按 0 计入状态分布。 `WorkspaceSummary`: @@ -385,7 +395,8 @@ argv、环境值和原始解析内容不得直接进入 `facts` 或 recovery com - repository、line、Objective、Task 计数; - Task 状态分布; - attention 计数和单一推荐动作; -- workspace snapshot digest。 +- workspace snapshot digest; +- `proof_inspection`,且只能是 `not_inspected`。独立 inspect 不写进这张卡。 `ConsoleWorkspace`: @@ -464,10 +475,11 @@ title 和 branch 是明确的展示字段,但仍受长度、Unicode 控制字 | 方法与路径 | 返回 | | --- | --- | -| `GET /api/v1/meta` | 版本、capabilities、session expiry | +| `GET /api/v1/meta` | 版本、`surfaces`(`overview` 与 `proofs`;`capabilities` 为兼容别名)、session expiry | | `GET /api/v1/overview?cursor=...&limit=...` | 已登记 workspace 的分页轻量摘要 | | `GET /api/v1/system` | 本机工具状态与已缓存更新状态 | -| `GET /api/v1/workspaces/{alias}` | 单 workspace 详情和 health 摘要 | +| `GET /api/v1/workspaces/{alias}` | 单 workspace 摘要卡,加上同一次 summary 快照的线 / 任务 / 目标清单 | +| `GET /api/v1/workspaces/{alias}/proofs` | 独立 Proof inspect;摘要保持未检查 | | `GET /api/v1/workspaces/{alias}/lines/{kind}/{line}` | 单 line 或 hotfix 详情 | | `GET /api/v1/workspaces/{alias}/graph?kind=...&line=...` | 组合图投影 | | `GET /api/v1/workspaces/{alias}/objectives` | Objective 摘要列表 | diff --git a/plans/delivery-physics-implementation.md b/plans/delivery-physics-implementation.md index 2e08c1a..2922e75 100644 --- a/plans/delivery-physics-implementation.md +++ b/plans/delivery-physics-implementation.md @@ -5,7 +5,7 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/0006-delivery-physics-and-capability-plane.md) 仲裁:[`docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md`](../docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md) 基线:`0.6.0` 已发布的 TaskGraph、证据绑定、Objective、只读 Console;`0.7.0` 已发布 Proof / Card / Compiler / `verify-bundle` -默认策略:先抽出投影,再衰减进调度,再换 Card,最后编译宿主;每一阶段未绿之前,下一阶段保持关闭。功能全部在 `0.7.x` 发布,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。 +默认策略:先抽出投影,再衰减进调度,再换 Card,最后编译宿主;每一阶段未绿之前,下一阶段保持关闭。功能和发版继续往前,版本号保持 `0.7.x`,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。 已锁定: @@ -20,7 +20,7 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 | --- | --- | | 1 | `proof verify` 默认 decay + rebind,不重跑 gate。`--rerun-procedure` 仅诊断,须 dry-run/隔离。 | | 2 | Console P7 留在 `0.7.x`,不另开 `0.8`。`0.7.0` 已发 P1–P6 + P8–P12a。P7 / `trigger_observation` / P13 走后续 `0.7.x`。 | -| 6 | 交付物理功能全部在 `0.7.x` 发布。取消 `0.8.0` / `0.9.0` 列车。`1.0.0` 只是身份冻结,不是本系列功能号;未显式要求不得打 `1.0.0`。 | +| 6 | 功能和发版继续往前,版本号保持 `0.7.x`。不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。`1.0.0` 只是以后的身份冻结,不是下一列功能车。 | | 3 | 宿主投影默认当前工作区。`tools.json` / PATH = `discovered_unintegrated`。`--user` 才写用户级 skill。 | | 4 | `contract_hash` 按 subject 拆:task 面 kind → attempt `task_contract_sha256`(缺则空);`action_receipt` → Objective `contract_sha256`。 | | 5 | `proof list` / `verify` 每次全量重派生。store 可丢弃,不是展示真源。 | @@ -59,8 +59,8 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 | 版本 | 主题 | 对用户可见 | 关闭条件未满足时 | | --- | --- | --- | --- | | `0.6.x` | 已发布维护 | 只接受与本计划不冲突的修复 | 不回写 `0.7` 物理列车 | -| `0.7.0` | 已发布:Proof / Card / Compiler / `verify-bundle` | `dyro proof list/show/verify`;`export`;`verify-bundle`;`dyro capability *`;`dyro host compile`;`dyro objective attention` 可含 `PROOF_DECAYED` | 已关闭。不得改号为 `0.6.x` 或 `1.0.0`;`trusted_usage` 不接入 BudgetUsage | -| `0.7.x` | 本系列剩余全部功能 | P7:Console 独立 inspect 展示已投影 Proof;`trigger_observation`;P13:陌生人核验与叙事锁。summary 仍 `not_inspected` | 不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号 | +| `0.7.0` | 已发布:Proof / Card / Compiler / `verify-bundle` | `dyro proof list/show/verify`;`export`;`verify-bundle`;`dyro capability *`;`dyro host compile`;`dyro objective attention` 可含 `PROOF_DECAYED` | 已关闭。不得改号为 `0.6.x` 或 `1.0.0` | +| `0.7.x` | 后续全部功能与发版 | 已发 P7 / `trigger_observation` / P13;后续产品面与新能力继续打 `0.7.2`、`0.7.3`…… | 不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号 | | `1.0.0` | 身份冻结,不是本系列功能号 | 仅当产品显式要求时才打 | 未显式要求不得标 `1.0.0` | `0.6.x` 继续只接受维护修复。本计划的剩余实现继续走 `0.7.x`,不回写 `0.6.0` 的已发布语义,也不把同一批功能改标成 `0.8` / `0.9` / `1.0`。 @@ -200,7 +200,7 @@ dyro proof verify [--dry-run] - 目标类型是 **`SchedulerSnapshot._payload`** 与 **`ProgressFacts` 装配点**(supervision / planner 交界)。**禁止**给未实例化的 `ContinuationSnapshot` 加 `proofs[]`。 - journal **不**持久化 proofs 当 PASS。`SchedulerReadProjection.schema_version` 保持 `1`;新字段缺省空,不进 merge 真源。 - planner / `ReasonCode` / `attention.py` / `_schedule_block_reason` 同步加 `PROOF_DECAYED`。attention 映射 `AttentionKind.NEEDS_USER`。默认 **不**用该码 block 下游。 -- `progress_fingerprint` 继续忽略 trigger 类 Proof。`0.7` **不**把 Proof 接入生产 `BudgetUsage`,不新开 no-progress 自动耗尽。文档承认:`decide_no_progress` 是已锁纯函数,生产未接线。 +- `progress_fingerprint` 继续忽略 trigger 类 Proof。**不**把 Proof 接入生产 `BudgetUsage`,不新开 no-progress 自动耗尽。`trusted_usage` 接入 `BudgetUsage.provider_usage_trusted` / `BudgetRequest.provider_usage_trusted`,默认 `false`。`objective tick` 对 automatic Objective 预览 `decide_budget(..., automatic=True)`;受监督 apply 保持 `automatic=False`。未信任硬停要求显式 `workspace.max_provider_usage`。文档承认:`decide_no_progress` 是已锁纯函数,生产未接线。 - 验收:同一 substrate 下 `build_scheduler_snapshot` digest 稳定;旧 journal 无 proof 字段兼容;`test_continuation_budgets` 仍绿;衰减后下一 tick 可出现 attention,不自动重跑 agent。 ### P5 · 交付门 decay 投影(A1) @@ -331,7 +331,7 @@ dyro capability test **0.7.0 已发布:** P1–P6 + P8–P12a 绿。旧工作区不改 toml 即可 `proof list`;merge / 下游对错与 0.6 相同,merge 错误路径只多 `PROOF_DECAYED` 人话。`0.7.0` tag 检查**不含** `verify-bundle` 硬门禁。 -**0.7.x 剩余实现已落地:** P7 独立 inspect 展示 Proof,summary 无新 git probe;`trigger_observation` 已派生;P13 陌生人核验与 `0.7.x` 叙事锁。旧 adapters 仍跑;未审计命令不能进自动执行;手改投影不能偷偷继续自动跑。全部打 `0.7.x`,不另开 `0.8.0` / `0.9.0` / `1.0.0`。未要求前不发 `0.7.1`。 +**0.7.x 继续:** P7 / `trigger_observation` / P13 已在 `0.7.1` 落地。后续功能与产品面收口继续开发和上线,号写成 `0.7.2`、`0.7.3`……,不另开 `0.8.0` / `0.9.0` / `1.0.0`。 **1.0.0:** 不是本系列功能出口。未显式要求不得标 `1.0.0`。 diff --git a/plans/dyro-agent-bridge-phase-0.md b/plans/dyro-agent-bridge-phase-0.md index e485609..fb2cfce 100644 --- a/plans/dyro-agent-bridge-phase-0.md +++ b/plans/dyro-agent-bridge-phase-0.md @@ -48,10 +48,12 @@ other steps are serial gates. ## 3. Step S1 — Core contracts and Exposure Catalog -Implementation status: Complete on 2026-08-06 for the source-tree unit gates. -At that milestone all operations remained deny-by-default; S5 has since -promoted only the seven Linux Mandatory Core Surface records after adding the -installed-artifact gate. +Implementation status: S1 source contracts were restored in this `0.7.x` +tree as `src/dyro/bridge/` without adding the package to the default wheel. +S2–S4 observation, plan, and source transport IDs are `implemented_testable` +everywhere. S5 promotes the seven Mandatory IDs to `public_available` only +on Linux. S6 is a source-only Skill plus `python -m dyro.bridge`. The +installable `dyro-bridge` extra and S7 MCP/Plugin are not started here. ### Context brief @@ -111,9 +113,12 @@ should require rollback. ## 4. Step S2 — Typed workspace resolution and observations -Implementation status (2026-08-06): source-tree Core work is complete and the -four S2 services are `implemented_testable`. They remain unavailable to public -Bridge callers until the S4 transport and S5 zero-effect/artifact gates pass. +Implementation status (2026-08-16): this `0.7.x` tree now has source-only +Bridge DTO adapters in `src/dyro/bridge/observations.py`. Catalog marks the +S2 observation IDs `implemented_testable`. They remain unavailable to public +Bridge callers: no transport, and the default wheel still omits `dyro.bridge`. +Authoritative Git observations (`task.explain`, `task.graph`) stay +`OPERATION_UNAVAILABLE` until B05. ### Context brief @@ -183,12 +188,13 @@ their own tests and do not alter human CLI semantics. ## 5. Step S3 — Typed deterministic plans -Implementation status (2026-08-07): the platform-gated source-tree Core and all -five Objective PLAN services are `implemented_testable`. Authoritative Git facts -are enabled only through a Linux `/proc/self/fd` boundary that binds the -worktree, Git directory, common directory, and object store. Other hosts fail -closed. The services remain unavailable to public Bridge callers until S4 and -the Linux S5 zero-effect/artifact/real-host gates pass. +Implementation status (2026-08-16): this `0.7.x` tree now has source-only +non-executable plan adapters in `src/dyro/bridge/plans.py`. The five Objective +PLAN services are `implemented_testable`. Plans set `executable=false` and +`authorization=none`, inspect neither integration nor Proofs, and do not +discover PATH providers. Authoritative Git-dependent facts stay unavailable +(no Linux `/proc/self/fd` adapter in this tree). The default wheel still +omits the package. ### Context brief @@ -259,11 +265,11 @@ remains intact. ## 6. Step S4 — One-shot JSON transport -Implementation status (2026-08-07): complete in the source tree. The package -entry point, bounded parser, static router, fixed error surface, fail-closed -PLAN handling, and broken-pipe behavior have focused tests. S5 has since made -the seven Linux Mandatory Core Surface operations publicly available; all -other operations and platforms remain fail-closed. +Implementation status (2026-08-16): this `0.7.x` tree now has a source-only +one-shot JSON transport in `src/dyro/bridge/transport.py`. Tests call +`handle_request` / `serve_once` with `exposure=testable`. Public exposure +stays empty. There is still no `dyro-bridge` console script and the default +wheel still omits `dyro.bridge`. ### Context brief @@ -319,11 +325,11 @@ and Plan services remain available to the Console/CLI if independently useful. ## 7. Step S5 — Zero-effect, artifact, and real-sandbox gates -Implementation status (2026-08-07): the catalog promotes exactly the seven -Mandatory Core Surface operations on Linux Ubuntu 24.04 and retains fail-closed -macOS/Windows metadata. The required CI gate runs the same 43-case corpus -against the internal candidate and installed public process from source, wheel, -and sdist; the exact-commit Docker evidence remains authoritative for Go. +Implementation status (2026-08-16): this `0.7.x` tree now platform-gates the +source catalog. Linux marks exactly the seven Mandatory Core Surface +operations `public_available`; darwin/Windows keep an empty public surface. +In-process zero-effect traps cover hello. There is still no `dyro-bridge` +script, no default-wheel package, and no CI/strace/Landlock artifact gate. ### Context brief @@ -380,6 +386,13 @@ property cannot be proven. Keep the harness as a regression tool. ## 8. Step S6 — Host-neutral Skill beta +Implementation status (2026-08-16): this `0.7.x` tree now has a source-only +Skill at `src/dyro/bridge/skill/` and a public process at +`python -m dyro.bridge`. The Skill is not registered with the integration +manager, not listed in package-data, and not installable. The published +wheel still omits `dyro.bridge` and does not grow `dyro-bridge` / +`dyro-mcp`. S7 MCP/Plugin is not started. + ### Context brief The Skill is progressive-disclosure guidance over a proven Phase 0 transport. @@ -418,6 +431,10 @@ prior version. Never delete an unowned same-name Skill. ## 9. Step S7 — Codex typed read-only MCP/Plugin +Implementation status (2026-08-17): not started. `0.7.x` will not add +`dyro-mcp`, an `[mcp]` extra, or MCP tools to the default wheel. That +matches the `0.6.3` shipping-surface removal. + ### Context brief MCP adapts the same Core services and exposes a small typed tool set. Server diff --git a/src/dyro/bridge/__init__.py b/src/dyro/bridge/__init__.py new file mode 100644 index 0000000..bdc80a2 --- /dev/null +++ b/src/dyro/bridge/__init__.py @@ -0,0 +1,50 @@ +"""Agent Bridge Phase 0 contracts. + +This package is source-tree only in the 0.7.x Core train. The default wheel +must not list ``dyro.bridge`` or grow ``dyro-bridge`` / ``dyro-mcp`` scripts. +S1 freezes models, the deny-by-default catalog, schemas, and identity hashes. +S2/S3 add source-only observation and plan adapters. S4 adds a source-only +one-shot JSON transport. S5 platform-gates Linux public availability in +source only. S6 adds ``python -m dyro.bridge`` and a non-installable Skill. +No packaged console script, mutation, or CLI handler lives here. +""" + +from .catalog import ( + EXCLUDED_OPERATION_IDS, + IMPLEMENTED_TESTABLE_IDS, + MANDATORY_OPERATION_IDS, + ExposureCatalog, + build_default_catalog, + catalog_platform, + compact_catalog, + validate_catalog, +) +from .identity import ( + CONFIG_REVISION_DOMAIN, + PROFILE_MAX_BYTES, + WORKSPACE_IDENTITY_DOMAIN, + config_revision_v1, + workspace_identity_v1, +) +from .models import Availability, ProtocolVersion, Risk +from .schemas import operation_schema + +__all__ = ( + "Availability", + "CONFIG_REVISION_DOMAIN", + "EXCLUDED_OPERATION_IDS", + "ExposureCatalog", + "IMPLEMENTED_TESTABLE_IDS", + "MANDATORY_OPERATION_IDS", + "PROFILE_MAX_BYTES", + "ProtocolVersion", + "Risk", + "WORKSPACE_IDENTITY_DOMAIN", + "build_default_catalog", + "catalog_platform", + "compact_catalog", + "config_revision_v1", + "operation_schema", + "validate_catalog", + "workspace_identity_v1", +) diff --git a/src/dyro/bridge/__main__.py b/src/dyro/bridge/__main__.py new file mode 100644 index 0000000..df0d2de --- /dev/null +++ b/src/dyro/bridge/__main__.py @@ -0,0 +1,29 @@ +"""Source-tree public process: ``python -m dyro.bridge``. + +This is not a packaged console script. The default wheel still omits +``dyro.bridge``, so an installed ``dyro`` distribution must not grow +``dyro-bridge``. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +from .transport import serve_once + + +def main() -> int: + try: + return serve_once( + sys.stdin.buffer, + sys.stdout.buffer, + cwd=Path.cwd(), + exposure="public", + ) + except BrokenPipeError: + return 5 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/dyro/bridge/catalog.py b/src/dyro/bridge/catalog.py new file mode 100644 index 0000000..4a6bba3 --- /dev/null +++ b/src/dyro/bridge/catalog.py @@ -0,0 +1,185 @@ +"""Deny-by-default Exposure Catalog. Metadata only; no CLI routing.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import sys + +from ..canonical import canonical_json_bytes +from ..errors import ValidationError +from .models import Availability, CatalogRecord, Risk + +IMPLEMENTED_TESTABLE_IDS = frozenset( + { + "bridge.hello", + "bridge.capabilities.compact", + "bridge.operation.schema", + "workspace.resolve", + "workspace.list", + "workspace.observe", + "line.list", + "task.list", + "task.gate_definitions.get", + "objective.list", + "objective.status", + "objective.plan", + "objective.explain", + "objective.graph", + "objective.tick", + "objective.attention", + } +) + +MANDATORY_OPERATION_IDS = frozenset( + { + "bridge.hello", + "bridge.capabilities.compact", + "bridge.operation.schema", + "workspace.resolve", + "workspace.list", + "workspace.observe", + "objective.plan", + } +) + +EXCLUDED_OPERATION_IDS = frozenset( + { + "task.gates", + "task.gates.run", + "task.run", + "task.next", + "task.loop", + "task.daemon", + "task.answer", + "objective.apply", + "objective.create", + "task.merge", + "task.push", + "line.merge", + "release.publish", + "workspace.update", + } +) + +_PHASE0_DECLARED: tuple[tuple[str, Risk, str], ...] = ( + ("bridge.hello", Risk.R0, "dyro.bridge.transport.hello"), + ("bridge.capabilities.compact", Risk.R0, "dyro.bridge.catalog.compact_catalog"), + ("bridge.operation.schema", Risk.R0, "dyro.bridge.schemas.operation_schema"), + ("workspace.resolve", Risk.R0, "dyro.bridge.observations.resolve_workspace"), + ("workspace.list", Risk.R0, "dyro.bridge.observations.list_workspaces"), + ("workspace.observe", Risk.R0, "dyro.bridge.observations.observe_workspace"), + ("line.list", Risk.R0, "dyro.bridge.observations.list_lines"), + ("task.list", Risk.R0, "dyro.bridge.observations.list_tasks"), + ("task.explain", Risk.R0, "dyro.bridge.observations.explain_task"), + ("task.graph", Risk.R0, "dyro.bridge.observations.task_graph"), + ("task.gate_definitions.get", Risk.R0, "dyro.bridge.observations.gate_definitions"), + ("objective.list", Risk.R0, "dyro.bridge.observations.list_objectives"), + ("objective.status", Risk.R0, "dyro.bridge.observations.objective_status"), + ("objective.plan", Risk.PLAN, "dyro.bridge.plans.objective_plan"), + ("objective.explain", Risk.PLAN, "dyro.bridge.plans.objective_explain"), + ("objective.graph", Risk.PLAN, "dyro.bridge.plans.objective_graph"), + ("objective.tick", Risk.PLAN, "dyro.bridge.plans.objective_tick"), + ("objective.attention", Risk.PLAN, "dyro.bridge.plans.objective_attention"), +) + + +@dataclass(frozen=True) +class ExposureCatalog: + operations: tuple[CatalogRecord, ...] + digest: str + + def record(self, operation_id: str) -> CatalogRecord | None: + for item in self.operations: + if item.id == operation_id: + return item + return None + + +def compact_catalog(catalog: ExposureCatalog) -> dict[str, object]: + return { + "schema_version": 1, + "operations": [ + { + "id": item.id, + "risk": item.risk.value, + "availability": item.availability.value, + "operation_schema_version": item.schema_version, + "must_be_available": item.must_be_available, + } + for item in catalog.operations + ], + } + + +def catalog_digest(operations: tuple[CatalogRecord, ...]) -> str: + payload = compact_catalog(ExposureCatalog(operations=operations, digest="")) + digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + return f"sha256:{digest}" + + +def validate_catalog(catalog: ExposureCatalog, *, release: bool = False) -> None: + ids = [item.id for item in catalog.operations] + if len(ids) != len(set(ids)): + raise ValidationError("Exposure Catalog 不能重复 operation") + excluded = sorted(set(ids) & EXCLUDED_OPERATION_IDS) + if excluded: + raise ValidationError(f"Phase 0 禁止这些 operation:{', '.join(excluded)}") + missing = sorted(MANDATORY_OPERATION_IDS - set(ids)) + if missing: + raise ValidationError(f"Exposure Catalog 缺少强制 operation:{', '.join(missing)}") + if release: + public = tuple( + item + for item in catalog.operations + if item.availability is Availability.PUBLIC_AVAILABLE + ) + if not public: + raise ValidationError("release catalog 不能是空的 public surface") + unpaid = sorted( + item.id + for item in catalog.operations + if item.must_be_available + and item.availability is not Availability.PUBLIC_AVAILABLE + ) + if unpaid: + raise ValidationError( + f"release catalog 强制 operation 尚未 public:{', '.join(unpaid)}" + ) + + +def catalog_platform(value: str | None = None) -> str: + raw = (value or sys.platform).lower() + if raw.startswith("linux"): + return "linux" + if raw.startswith("darwin"): + return "darwin" + if raw.startswith("win"): + return "win32" + return "unsupported" + + +def operation_availability(operation_id: str, *, platform: str | None = None) -> Availability: + host = catalog_platform(platform) + if host == "linux" and operation_id in MANDATORY_OPERATION_IDS: + return Availability.PUBLIC_AVAILABLE + if operation_id in IMPLEMENTED_TESTABLE_IDS: + return Availability.IMPLEMENTED_TESTABLE + return Availability.DECLARED + + +def build_default_catalog(*, platform: str | None = None) -> ExposureCatalog: + operations = tuple( + CatalogRecord( + id=operation_id, + risk=risk, + availability=operation_availability(operation_id, platform=platform), + schema_version=1, + must_be_available=operation_id in MANDATORY_OPERATION_IDS, + core_service=service, + ) + for operation_id, risk, service in _PHASE0_DECLARED + ) + catalog = ExposureCatalog(operations=operations, digest=catalog_digest(operations)) + validate_catalog(catalog, release=False) + return catalog diff --git a/src/dyro/bridge/constants.py b/src/dyro/bridge/constants.py new file mode 100644 index 0000000..3e765d9 --- /dev/null +++ b/src/dyro/bridge/constants.py @@ -0,0 +1,12 @@ +"""Stable Bridge planner and observation revisions.""" + +from __future__ import annotations + +OBSERVATION_REVISION = "workspace-observe/1" +PLANNER_REVISIONS = { + "objective.plan": "objective-plan/1", + "objective.explain": "objective-explain/1", + "objective.graph": "objective-graph/1", + "objective.tick": "objective-tick/1", + "objective.attention": "objective-attention/1", +} diff --git a/src/dyro/bridge/identity.py b/src/dyro/bridge/identity.py new file mode 100644 index 0000000..1df1eb1 --- /dev/null +++ b/src/dyro/bridge/identity.py @@ -0,0 +1,41 @@ +"""WorkspaceIdentityV1 and ConfigRevisionV1. Not credentials.""" + +from __future__ import annotations + +from pathlib import Path + +from ..canonical import canonical_json_bytes +from ..errors import ValidationError + +WORKSPACE_IDENTITY_DOMAIN = b"dyro.workspace.identity/v1\0" +CONFIG_REVISION_DOMAIN = b"dyro.config.raw/v1\0" +PROFILE_MAX_BYTES = 1_048_576 + + +def workspace_identity_v1(*, canonical_root: Path, profile_name: str) -> str: + """Return ``workspace:``. Changing root or name changes the value.""" + if not isinstance(canonical_root, Path): + raise ValidationError("canonical_root 必须是路径") + if not isinstance(profile_name, str) or not profile_name.strip(): + raise ValidationError("profile_name 必须是非空字符串") + payload = { + "canonical_root": canonical_root.resolve().as_posix(), + "profile_name": profile_name.strip(), + } + digest = _sha256(WORKSPACE_IDENTITY_DOMAIN + canonical_json_bytes(payload)) + return f"workspace:{digest}" + + +def config_revision_v1(profile_bytes: bytes) -> str: + """Hash exact Profile bytes after the file is proven bounded.""" + if not isinstance(profile_bytes, (bytes, bytearray)): + raise ValidationError("Profile 必须是字节") + if len(profile_bytes) > PROFILE_MAX_BYTES: + raise ValidationError("Profile 超过 Phase 0 字节上限") + return _sha256(CONFIG_REVISION_DOMAIN + bytes(profile_bytes)) + + +def _sha256(payload: bytes) -> str: + import hashlib + + return hashlib.sha256(payload).hexdigest() diff --git a/src/dyro/bridge/models.py b/src/dyro/bridge/models.py new file mode 100644 index 0000000..4c03ff6 --- /dev/null +++ b/src/dyro/bridge/models.py @@ -0,0 +1,58 @@ +"""Frozen Agent Bridge protocol types. No CLI or filesystem access.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class Risk(str, Enum): + R0 = "R0" + PLAN = "PLAN" + R1 = "R1" + R2 = "R2" + R3 = "R3" + + +class Availability(str, Enum): + DECLARED = "declared" + IMPLEMENTED_TESTABLE = "implemented_testable" + PUBLIC_AVAILABLE = "public_available" + + +@dataclass(frozen=True) +class ProtocolVersion: + major: int + minor: int + + def __post_init__(self) -> None: + if type(self.major) is not int or type(self.minor) is not int: + raise TypeError("ProtocolVersion 必须是整数") + if self.major < 1 or self.minor < 0: + raise TypeError("ProtocolVersion 无效") + + +@dataclass(frozen=True) +class CatalogRecord: + id: str + risk: Risk + availability: Availability + schema_version: int + must_be_available: bool + core_service: str + + def __post_init__(self) -> None: + if not self.id or not isinstance(self.id, str): + raise TypeError("CatalogRecord.id 不能为空") + if not isinstance(self.risk, Risk): + raise TypeError("CatalogRecord.risk 必须是 Risk") + if not isinstance(self.availability, Availability): + raise TypeError("CatalogRecord.availability 必须是 Availability") + if type(self.schema_version) is not int or self.schema_version < 1: + raise TypeError("CatalogRecord.schema_version 必须是正整数") + if type(self.must_be_available) is not bool: + raise TypeError("CatalogRecord.must_be_available 必须是 bool") + if not self.core_service or not isinstance(self.core_service, str): + raise TypeError("CatalogRecord.core_service 不能为空") + if self.core_service.startswith("dyro.cli"): + raise TypeError("CatalogRecord.core_service 不得指向 CLI") diff --git a/src/dyro/bridge/observations.py b/src/dyro/bridge/observations.py new file mode 100644 index 0000000..30ca3a2 --- /dev/null +++ b/src/dyro/bridge/observations.py @@ -0,0 +1,276 @@ +"""Typed, path-free Bridge observations. No CLI, recovery, or gate execution.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +from ..config import Config +from ..continuation.resolution import ( + WorkspaceResolutionError, + resolve_workspace_readonly, +) +from ..continuation.store import get_objective, list_objectives +from ..errors import DyroError, ValidationError +from ..hub import load_registry_bounded +from ..observations import capture_workspace_read_snapshot +from ..read_limits import ObservationLimits, ReadBudget +from ..tasks import list_tasks, load_task +from ..workspace import list_lines +from .identity import config_revision_v1, workspace_identity_v1 + +UNAVAILABLE = "OPERATION_UNAVAILABLE" + + +class BridgeObservationError(DyroError): + def __init__(self, code: str, message: str = "") -> None: + super().__init__(message or code) + self.code = code + + +def default_read_budget() -> ReadBudget: + return ReadBudget(ObservationLimits()) + + +def _budget(budget: ReadBudget | None) -> ReadBudget: + return budget if budget is not None else default_read_budget() + + +def _clock(clock: Callable[[], datetime] | None) -> Callable[[], datetime]: + return clock if clock is not None else (lambda: datetime.now(timezone.utc)) + + +def _resolve(*, start, workspace, cwd, budget): + try: + return resolve_workspace_readonly( + start=start, workspace=workspace, cwd=cwd, budget=budget + ) + except WorkspaceResolutionError as exc: + raise BridgeObservationError(exc.code.value) from exc + + +def resolve_workspace_observation( + *, + start: str | Path | None, + workspace: str | None, + cwd: Path, + budget: ReadBudget | None = None, +) -> dict[str, object]: + resolved = _resolve(start=start, workspace=workspace, cwd=cwd, budget=_budget(budget)) + config = resolved.profile.config + return { + "workspace": { + "id": workspace_identity_v1( + canonical_root=resolved.profile.root, profile_name=config.name + ), + "name": config.name, + }, + "resolution_source": resolved.source.value, + "config_revision": config_revision_v1(resolved.profile.profile_bytes), + } + + +def list_workspaces_observation(*, budget: ReadBudget | None = None) -> dict[str, object]: + limits = _budget(budget) + registry = load_registry_bounded(limits) + items: list[dict[str, object]] = [] + failures: list[dict[str, str]] = [] + for record in registry.workspaces: + try: + profile = resolve_workspace_readonly( + start=None, + workspace=record.name, + cwd=Path("/"), + budget=limits, + ).profile + items.append( + { + "alias": record.name, + "name": profile.config.name, + "status": "ok", + "is_default": record.name == registry.default, + } + ) + except WorkspaceResolutionError as exc: + status = ( + "unreadable" + if exc.code.value == "HOST_READ_PERMISSION_REQUIRED" + else "stale" + ) + items.append( + { + "alias": record.name, + "name": record.name, + "status": status, + "is_default": record.name == registry.default, + } + ) + failures.append({"component": record.name, "code": exc.code.value}) + return { + "partial": bool(failures), + "workspaces": items, + "failures": failures, + } + + +def observe_workspace( + *, + start: str | Path | None, + workspace: str | None, + cwd: Path, + budget: ReadBudget | None = None, + clock: Callable[[], datetime] | None = None, +) -> dict[str, object]: + resolved = _resolve(start=start, workspace=workspace, cwd=cwd, budget=_budget(budget)) + snapshot = capture_workspace_read_snapshot(resolved.profile.config, clock=_clock(clock)) + return { + "workspace": { + "id": workspace_identity_v1( + canonical_root=resolved.profile.root, + profile_name=resolved.profile.config.name, + ), + "name": snapshot.workspace_name, + }, + "resolution_source": resolved.source.value, + "integration_inspection": "not_inspected", + "proof_inspection": snapshot.proof_inspection, + "completeness": snapshot.completeness, + "lines": [ + { + "id": item.id, + "kind": item.kind, + "branch": item.branch, + "base": item.base, + "repository_count": item.repository_count, + } + for item in snapshot.lines + ], + "tasks": [ + { + "id": item.id, + "title": item.title, + "line": item.line, + "status": item.status, + "risk": item.risk, + "depends_on": list(item.depends_on), + "blocked_on": list(item.blocked_on), + "conflict_group": item.conflict_group, + "executor": item.executor, + "reviewer": item.reviewer, + "integration_state": "not_inspected", + "external_claim_active": item.external_claim_active, + } + for item in snapshot.tasks + ], + "objectives": [ + { + "id": item.id, + "title": item.title, + "line": item.line, + "revision": item.revision, + "operator_state": item.operator_state, + "requested_mode": item.requested_mode, + "operations": list(item.operations), + "scope_count": item.scope_count, + } + for item in snapshot.objectives + ], + "failures": [failure.__dict__ for failure in snapshot.failures], + } + + +def list_lines_observation(config: Config) -> dict[str, object]: + return { + "lines": [ + { + "id": line.id, + "kind": line.kind, + "branch": line.branch, + "base": line.base, + "repository_count": len(line.repositories), + } + for line in list_lines(config) + ] + } + + +def list_tasks_observation(config: Config) -> dict[str, object]: + return { + "integration_inspection": "not_inspected", + "tasks": [ + { + "id": task.id, + "title": task.title, + "line": task.line, + "risk": task.risk, + "executor": task.executor, + "reviewer": task.reviewer, + "depends_on": list(task.depends_on), + "blocked_on": list(task.blocked_on), + "conflict_group": task.conflict_group, + } + for task in list_tasks(config) + ], + } + + +def list_objectives_observation(config: Config) -> dict[str, object]: + records = list_objectives(config, recover=False) + return { + "objectives": [ + { + "id": record.objective.id, + "title": record.objective.title, + "line": record.objective.line, + "revision": record.revision, + "operator_state": record.operator_state, + "requested_mode": record.objective.requested_mode.value, + } + for record in records + ] + } + + +def objective_status_observation(config: Config, objective_id: str) -> dict[str, object]: + record = get_objective(config, objective_id, recover=False) + return { + "id": record.objective.id, + "title": record.objective.title, + "line": record.objective.line, + "revision": record.revision, + "operator_state": record.operator_state, + "requested_mode": record.objective.requested_mode.value, + "integration_inspection": "not_inspected", + "ready": None, + "blocked": None, + } + + +def gate_definitions(config: Config, task_id: str) -> dict[str, object]: + try: + task = load_task(config, task_id) + except (DyroError, ValidationError, OSError) as exc: + raise BridgeObservationError("TASK_NOT_FOUND") from exc + return { + "task_id": task.id, + "gates": [ + {"name": gate.name, "timeout_seconds": gate.timeout_seconds} + for gate in task.gates + ], + } + + +def explain_task(_config: Config, _task_id: str) -> None: + unavailable_git_observation("task.explain") + + +def task_graph(_config: Config, _task_id: str) -> None: + unavailable_git_observation("task.graph") + + +def unavailable_git_observation(operation: str) -> None: + raise BridgeObservationError( + UNAVAILABLE, + f"{operation} 需要已审查的 Git 观察适配器", + ) diff --git a/src/dyro/bridge/parse.py b/src/dyro/bridge/parse.py new file mode 100644 index 0000000..43dce19 --- /dev/null +++ b/src/dyro/bridge/parse.py @@ -0,0 +1,225 @@ +"""Bounded, duplicate-key-aware JSON parser for one-shot Bridge requests.""" + +from __future__ import annotations + +MAX_REQUEST_BYTES = 256 * 1024 +MAX_DEPTH = 64 +MAX_NODES = 10_000 +MAX_NUMERIC_TOKEN = 128 + + +class BoundedJSONError(ValueError): + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +def load_bounded_json(raw: bytes) -> object: + if not isinstance(raw, (bytes, bytearray)): + raise BoundedJSONError("INVALID_JSON") + if len(raw) > MAX_REQUEST_BYTES: + raise BoundedJSONError("REQUEST_TOO_LARGE") + try: + text = bytes(raw).decode("utf-8") + except UnicodeDecodeError as exc: + raise BoundedJSONError("INVALID_JSON") from exc + return _Parser(text).parse() + + +class _Parser: + def __init__(self, text: str) -> None: + self.text = text + self.i = 0 + self.n = len(text) + self.nodes = 0 + + def parse(self) -> object: + self._skip() + if self.i >= self.n: + raise BoundedJSONError("INVALID_JSON") + value = self._value(1) + self._skip() + if self.i != self.n: + raise BoundedJSONError("INVALID_JSON") + return value + + def _value(self, depth: int) -> object: + if depth > MAX_DEPTH: + raise BoundedJSONError("INVALID_JSON") + self._count() + ch = self._peek() + if ch == "{": + return self._object(depth) + if ch == "[": + return self._array(depth) + if ch == '"': + return self._string() + if ch == "-" or ch.isdigit(): + return self._number() + if self._take("true"): + return True + if self._take("false"): + return False + if self._take("null"): + return None + raise BoundedJSONError("INVALID_JSON") + + def _object(self, depth: int) -> dict[str, object]: + self._eat("{") + self._skip() + if self._peek() == "}": + self.i += 1 + return {} + result: dict[str, object] = {} + while True: + self._skip() + if self._peek() != '"': + raise BoundedJSONError("INVALID_JSON") + key = self._string() + if key in result: + raise BoundedJSONError("INVALID_JSON") + self._skip() + self._eat(":") + self._skip() + result[key] = self._value(depth + 1) + self._skip() + ch = self._peek() + if ch == ",": + self.i += 1 + continue + if ch == "}": + self.i += 1 + return result + raise BoundedJSONError("INVALID_JSON") + + def _array(self, depth: int) -> list[object]: + self._eat("[") + self._skip() + if self._peek() == "]": + self.i += 1 + return [] + items: list[object] = [] + while True: + self._skip() + items.append(self._value(depth + 1)) + self._skip() + ch = self._peek() + if ch == ",": + self.i += 1 + continue + if ch == "]": + self.i += 1 + return items + raise BoundedJSONError("INVALID_JSON") + + def _string(self) -> str: + self._eat('"') + out: list[str] = [] + while self.i < self.n: + ch = self.text[self.i] + self.i += 1 + if ch == '"': + return "".join(out) + if ch == "\\": + out.append(self._escape()) + continue + if ord(ch) < 0x20: + raise BoundedJSONError("INVALID_JSON") + out.append(ch) + raise BoundedJSONError("INVALID_JSON") + + def _escape(self) -> str: + if self.i >= self.n: + raise BoundedJSONError("INVALID_JSON") + ch = self.text[self.i] + self.i += 1 + mapping = { + '"': '"', + "\\": "\\", + "/": "/", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + } + if ch in mapping: + return mapping[ch] + if ch != "u": + raise BoundedJSONError("INVALID_JSON") + if self.i + 4 > self.n: + raise BoundedJSONError("INVALID_JSON") + hex_digits = self.text[self.i : self.i + 4] + self.i += 4 + try: + code = int(hex_digits, 16) + except ValueError as exc: + raise BoundedJSONError("INVALID_JSON") from exc + if 0xD800 <= code <= 0xDFFF: + raise BoundedJSONError("INVALID_JSON") + return chr(code) + + def _number(self) -> int | float: + start = self.i + if self._peek() == "-": + self.i += 1 + if self.i >= self.n or not self.text[self.i].isdigit(): + raise BoundedJSONError("INVALID_JSON") + if self.text[self.i] == "0": + self.i += 1 + else: + while self.i < self.n and self.text[self.i].isdigit(): + self.i += 1 + is_float = False + if self.i < self.n and self.text[self.i] == ".": + is_float = True + self.i += 1 + if self.i >= self.n or not self.text[self.i].isdigit(): + raise BoundedJSONError("INVALID_JSON") + while self.i < self.n and self.text[self.i].isdigit(): + self.i += 1 + if self.i < self.n and self.text[self.i] in "eE": + is_float = True + self.i += 1 + if self.i < self.n and self.text[self.i] in "+-": + self.i += 1 + if self.i >= self.n or not self.text[self.i].isdigit(): + raise BoundedJSONError("INVALID_JSON") + while self.i < self.n and self.text[self.i].isdigit(): + self.i += 1 + token = self.text[start : self.i] + if len(token) > MAX_NUMERIC_TOKEN: + raise BoundedJSONError("INVALID_JSON") + try: + return float(token) if is_float else int(token) + except ValueError as exc: + raise BoundedJSONError("INVALID_JSON") from exc + + def _count(self) -> None: + self.nodes += 1 + if self.nodes > MAX_NODES: + raise BoundedJSONError("INVALID_JSON") + + def _skip(self) -> None: + while self.i < self.n and self.text[self.i] in " \t\r\n": + self.i += 1 + + def _peek(self) -> str: + if self.i >= self.n: + raise BoundedJSONError("INVALID_JSON") + return self.text[self.i] + + def _eat(self, expected: str) -> None: + if self.i >= self.n or self.text[self.i] != expected: + raise BoundedJSONError("INVALID_JSON") + self.i += 1 + + def _take(self, literal: str) -> bool: + end = self.i + len(literal) + if self.text[self.i : end] == literal: + after = self.text[end : end + 1] + if after and after.isalnum(): + return False + self.i = end + return True + return False diff --git a/src/dyro/bridge/plans.py b/src/dyro/bridge/plans.py new file mode 100644 index 0000000..7319895 --- /dev/null +++ b/src/dyro/bridge/plans.py @@ -0,0 +1,213 @@ +"""Deterministic, non-executable Bridge plans. No apply, lease, or reservation.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +from typing import Callable + +from ..canonical import canonical_json_bytes +from ..config import Config +from ..errors import DyroError, ValidationError +from ..continuation.attention import ( + attention_projection_payload, + build_attention_projection, +) +from ..continuation.engine import build_scheduler_tick, scheduler_tick_payload +from ..continuation.planner import ( + build_continuation_plan, + build_scheduler_projection, + continuation_plan_payload, + projection_payload, +) +from ..continuation.snapshot import build_scheduler_snapshot +from ..continuation.store import get_objective +from .constants import PLANNER_REVISIONS +from .identity import config_revision_v1, workspace_identity_v1 +from .observations import BridgeObservationError + + +def _clock(clock: Callable[[], datetime] | None) -> Callable[[], datetime]: + return clock if clock is not None else (lambda: datetime.now(timezone.utc)) + + +def _identity(config: Config, profile_bytes: bytes) -> tuple[str, str]: + return ( + workspace_identity_v1(canonical_root=config.root, profile_name=config.name), + config_revision_v1(profile_bytes), + ) + + +def _envelope( + *, + operation: str, + workspace_id: str, + config_revision: str, + normalized_input: dict[str, object], + read_set: dict[str, object], + projection: dict[str, object], +) -> dict[str, object]: + payload: dict[str, object] = { + "authorization": "none", + "effects": [], + "effective_risk": "PLAN", + "executable": False, + "maximum_risk": "PLAN", + "normalized_input": normalized_input, + "operation": operation, + "operation_schema_version": 1, + "planner_revision": PLANNER_REVISIONS[operation], + "projection": projection, + "protocol_major": 1, + "read_set": read_set, + "warnings": [], + "workspace": { + "config_sha256": f"sha256:{config_revision}", + "id": workspace_id, + }, + } + digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + payload["plan_sha256"] = f"sha256:{digest}" + return payload + + +def _snapshot(config: Config, objective_id: str, clock: Callable[[], datetime]): + try: + record = get_objective(config, objective_id, recover=False) + except (DyroError, ValidationError, OSError) as exc: + raise BridgeObservationError("OBJECTIVE_NOT_FOUND") from exc + snapshot = build_scheduler_snapshot( + config, + objective=record, + clock=clock, + inspect_integration=False, + inspect_proofs=False, + ) + return record, snapshot, build_continuation_plan(snapshot) + + +def _read_set(record, snapshot) -> dict[str, object]: + return { + "execution_mode": snapshot.execution_mode, + "integration_inspection": "not_inspected", + "objective": { + "contract_sha256": record.contract_sha256, + "event_sha256": record.event_sha256, + "id": record.objective.id, + "operator_state": record.operator_state, + "requested_mode": record.objective.requested_mode.value, + "revision": record.revision, + "scope_sha256": record.scope_sha256, + }, + "observed_at": snapshot.observed_at.isoformat(), + "snapshot_sha256": snapshot.snapshot_sha256, + } + + +def objective_plan( + config: Config, + objective_id: str, + *, + profile_bytes: bytes, + clock: Callable[[], datetime] | None = None, +) -> dict[str, object]: + workspace_id, revision = _identity(config, profile_bytes) + record, snapshot, plan = _snapshot(config, objective_id, _clock(clock)) + return _envelope( + operation="objective.plan", + workspace_id=workspace_id, + config_revision=revision, + normalized_input={"objective_id": objective_id}, + read_set=_read_set(record, snapshot), + projection=continuation_plan_payload(plan), + ) + + +def objective_explain( + config: Config, + objective_id: str, + *, + profile_bytes: bytes, + clock: Callable[[], datetime] | None = None, +) -> dict[str, object]: + workspace_id, revision = _identity(config, profile_bytes) + record, snapshot, plan = _snapshot(config, objective_id, _clock(clock)) + continuation = continuation_plan_payload(plan) + return _envelope( + operation="objective.explain", + workspace_id=workspace_id, + config_revision=revision, + normalized_input={"objective_id": objective_id}, + read_set=_read_set(record, snapshot), + projection={ + "completion": plan.completion.value, + "selected_actions": continuation["selected_actions"], + "blocked": continuation["blocked"], + "attention": continuation["attention"], + }, + ) + + +def objective_graph( + config: Config, + objective_id: str, + *, + profile_bytes: bytes, + clock: Callable[[], datetime] | None = None, +) -> dict[str, object]: + workspace_id, revision = _identity(config, profile_bytes) + record, snapshot, plan = _snapshot(config, objective_id, _clock(clock)) + projection = build_scheduler_projection(snapshot, plan) + return _envelope( + operation="objective.graph", + workspace_id=workspace_id, + config_revision=revision, + normalized_input={"objective_id": objective_id}, + read_set=_read_set(record, snapshot), + projection=projection_payload(projection), + ) + + +def objective_tick( + config: Config, + objective_id: str, + *, + profile_bytes: bytes, + clock: Callable[[], datetime] | None = None, +) -> dict[str, object]: + workspace_id, revision = _identity(config, profile_bytes) + record, snapshot, plan = _snapshot(config, objective_id, _clock(clock)) + tick = build_scheduler_tick( + snapshot, plan, max_parallel=record.objective.budget.max_parallel + ) + return _envelope( + operation="objective.tick", + workspace_id=workspace_id, + config_revision=revision, + normalized_input={"objective_id": objective_id}, + read_set=_read_set(record, snapshot), + projection=scheduler_tick_payload(tick), + ) + + +def objective_attention( + config: Config, + objective_id: str, + *, + profile_bytes: bytes, + clock: Callable[[], datetime] | None = None, +) -> dict[str, object]: + workspace_id, revision = _identity(config, profile_bytes) + record, snapshot, plan = _snapshot(config, objective_id, _clock(clock)) + scheduler = build_scheduler_projection(snapshot, plan) + attention = build_attention_projection( + snapshot, plan, scheduler, budget=record.objective.budget + ) + return _envelope( + operation="objective.attention", + workspace_id=workspace_id, + config_revision=revision, + normalized_input={"objective_id": objective_id}, + read_set=_read_set(record, snapshot), + projection=attention_projection_payload(attention), + ) diff --git a/src/dyro/bridge/redaction.py b/src/dyro/bridge/redaction.py new file mode 100644 index 0000000..76cf93d --- /dev/null +++ b/src/dyro/bridge/redaction.py @@ -0,0 +1,47 @@ +"""Boundary redaction for request IDs, paths, credentials, and argv.""" + +from __future__ import annotations + +import re + +REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_URL_CREDENTIAL = re.compile(r"://[^/\s:]+:[^/\s@]+@") +_SECRET = re.compile( + r"(?i)(api[_-]?key|password|secret|authorization|bearer\s+[A-Za-z0-9._\-+=/]+)" +) +_MAX_MESSAGE = 4096 + + +def looks_like_absolute_path(value: str) -> bool: + if value.startswith("/") and len(value) > 1: + return True + return ( + len(value) >= 3 + and value[0].isalpha() + and value[1] == ":" + and value[2] in "\\/" + ) + + +def looks_sensitive(value: str) -> bool: + if looks_like_absolute_path(value): + return True + if _URL_CREDENTIAL.search(value) or _SECRET.search(value): + return True + return False + + +def echo_request_id(value: object) -> tuple[str | None, bool]: + """Return a safe request_id and whether it was redacted.""" + if not isinstance(value, str) or not REQUEST_ID_RE.fullmatch(value): + return None, True + if looks_sensitive(value): + return None, True + return value, False + + +def presentation_message(text: str) -> str: + cleaned = "".join(ch for ch in text if ch >= " " or ch in "\t") + if len(cleaned) > _MAX_MESSAGE: + return cleaned[:_MAX_MESSAGE] + return cleaned diff --git a/src/dyro/bridge/schemas.py b/src/dyro/bridge/schemas.py new file mode 100644 index 0000000..1d33cf6 --- /dev/null +++ b/src/dyro/bridge/schemas.py @@ -0,0 +1,161 @@ +"""Per-operation JSON Schema. Fetch one allowlisted ID; reject unknown.""" + +from __future__ import annotations + +from ..errors import ValidationError +from .catalog import build_default_catalog + +_OBJECT = {"type": "object", "additionalProperties": False} +_WORKSPACE_SELECTOR = { + "type": "object", + "additionalProperties": False, + "properties": { + "workspace": {"type": ["string", "null"]}, + "start": {"type": "string"}, + }, +} +_OBJECTIVE_SELECTOR = { + "type": "object", + "additionalProperties": False, + "required": ["objective_id"], + "properties": { + "workspace": {"type": ["string", "null"]}, + "start": {"type": "string"}, + "objective_id": {"type": "string"}, + }, +} +_TASK_SELECTOR = { + "type": "object", + "additionalProperties": False, + "required": ["task_id"], + "properties": { + "workspace": {"type": ["string", "null"]}, + "start": {"type": "string"}, + "task_id": {"type": "string"}, + }, +} + +_SCHEMAS: dict[str, dict[str, object]] = { + "bridge.hello": { + "input": {**_OBJECT, "properties": {}}, + "output": { + "type": "object", + "additionalProperties": False, + "required": ["protocol", "dyro_version", "bridge_version"], + "properties": { + "protocol": {"type": "object"}, + "dyro_version": {"type": "string"}, + "bridge_version": {"type": "string"}, + }, + }, + }, + "bridge.capabilities.compact": { + "input": {**_OBJECT, "properties": {}}, + "output": { + "type": "object", + "additionalProperties": False, + "required": ["schema_version", "operations"], + "properties": { + "schema_version": {"type": "integer"}, + "operations": {"type": "array"}, + }, + }, + }, + "bridge.operation.schema": { + "input": { + "type": "object", + "additionalProperties": False, + "required": ["operation"], + "properties": {"operation": {"type": "string"}}, + }, + "output": { + "type": "object", + "additionalProperties": False, + "required": ["operation", "schema_version", "input", "output"], + "properties": { + "operation": {"type": "string"}, + "schema_version": {"type": "integer"}, + "input": {"type": "object"}, + "output": {"type": "object"}, + }, + }, + }, + "workspace.resolve": {"input": _WORKSPACE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "workspace.list": {"input": {**_OBJECT, "properties": {}}, "output": {**_OBJECT, "properties": {}}}, + "workspace.observe": {"input": _WORKSPACE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "line.list": {"input": _WORKSPACE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "task.list": {"input": _WORKSPACE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "task.gate_definitions.get": {"input": _TASK_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.list": {"input": _WORKSPACE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.status": {"input": _OBJECTIVE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.plan": {"input": _OBJECTIVE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.explain": {"input": _OBJECTIVE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.graph": {"input": _OBJECTIVE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.tick": {"input": _OBJECTIVE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, + "objective.attention": {"input": _OBJECTIVE_SELECTOR, "output": {**_OBJECT, "properties": {}}}, +} + + +def validate_input(schema: dict[str, object], value: object, *, label: str = "input") -> None: + expected = schema.get("type", "object") + allowed = expected if isinstance(expected, list) else [expected] + if not any(_type_matches(value, item) for item in allowed): + raise ValidationError(f"{label} 类型无效") + if "object" not in allowed or not isinstance(value, dict): + return + properties = schema.get("properties") + if not isinstance(properties, dict): + properties = {} + if schema.get("additionalProperties") is False: + unknown = sorted(set(value) - set(properties)) + if unknown: + raise ValidationError(f"{label} 包含未知字段") + required = schema.get("required", []) + if isinstance(required, list): + missing = [name for name in required if name not in value] + if missing: + raise ValidationError(f"{label} 缺少字段") + for name, item in value.items(): + child = properties.get(name) + if isinstance(child, dict): + validate_input(child, item, label=f"{label}.{name}") + + +def _type_matches(value: object, expected: object) -> bool: + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + if expected == "null": + return value is None + return False + + +def operation_schema( + operation_id: str, *, catalog=None, platform: str | None = None +) -> dict[str, object]: + catalog = catalog if catalog is not None else build_default_catalog(platform=platform) + record = catalog.record(operation_id) + if record is None: + raise ValidationError(f"未知 operation:{operation_id}") + schema = _SCHEMAS.get(operation_id) + if schema is None: + schema = { + "input": {**_OBJECT, "properties": {}}, + "output": {**_OBJECT, "properties": {}}, + } + return { + "operation": operation_id, + "schema_version": record.schema_version, + "availability": record.availability.value, + "input": schema["input"], + "output": schema["output"], + } diff --git a/src/dyro/bridge/skill/SKILL.md b/src/dyro/bridge/skill/SKILL.md new file mode 100644 index 0000000..9919a6a --- /dev/null +++ b/src/dyro/bridge/skill/SKILL.md @@ -0,0 +1,75 @@ +--- +name: dyro-agent-bridge +description: Inspect and plan Dyro state through the source-only Agent Bridge public process. Use for workspace discovery, bounded observations, and non-executable Objective plans. Never use for execution, apply, dispatch, or delivery mutations. +--- + +# Dyro Agent Bridge + +Treat Agent Bridge as a one-shot inspect-and-plan process. It is not an +authorization boundary and is not `dispatch`. + +This Skill is source-tree only in the `0.7.x` train. Do not install it into a +host discovery directory, and do not invent a `dyro-bridge` console script. + +## Process + +If `python -m dyro.bridge` cannot be imported, stop. The published wheel is +bridge-free; that is unsupported, not a reason to use a write-capable command. + +Each call is one UTF-8 JSON object on stdin, then close stdin. Accept exactly +one JSON object and one newline on stdout. Empty stderr is required. Exit `4` +or `error.code=OPERATION_UNAVAILABLE` means this host has no public Bridge +surface—report unsupported and stop. Do not retry with `dyro`, `dispatch`, +`apply`, or a guessed CLI flag. + +## Routing + +1. First call `bridge.capabilities.compact`. +2. Fetch exactly one `bridge.operation.schema` for the chosen operation. +3. Call only that operation if compact lists it `public_available`. + +Allowlisted operations after a public compact: + +- `workspace.resolve` +- `workspace.list` +- `workspace.observe` +- `objective.plan` + +`implemented_testable` and `declared` operations stay unavailable through this +process. `line.list`, `task.list`, explain, graph, tick, and attention are not +public in Phase 0. + +Positive triggers: workspace identity, inventory, observation, or a +non-executable Objective plan. + +Negative triggers: apply, dispatch, task run, gates, merge, push, sign-off, +release, publish, console, install, or any confirmation/approval field. + +## Request + +```json +{ + "protocol": {"major": 1, "minor": 0}, + "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.1"}, + "operation": "bridge.capabilities.compact", + "input": {} +} +``` + +Required fields are `protocol.major`, `client.name`, `client.version`, +`operation`, and `input`. Do not send `actor`, `approval`, `confirmation`, +`command`, `argv`, `shell`, `apply`, or `dry_run`. + +## Response + +Keep evidence separate from judgment. `ok=true` data is observed. A plan with +`executable=false` and `authorization=none` is not permission to act. Absolute +paths, argv, and logs must not be repeated. + +## Hard safety boundary + +- Do not run `dyro console`, `dyro dispatch`, `objective apply`, `task run`, + `task gates`, merge, push, or integration install. +- Do not edit project or Dyro state files. +- Do not treat a plan digest as approval. +- End after observation and planning. diff --git a/src/dyro/bridge/skill/agents/openai.yaml b/src/dyro/bridge/skill/agents/openai.yaml new file mode 100644 index 0000000..3aecdac --- /dev/null +++ b/src/dyro/bridge/skill/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dyro Agent Bridge" + short_description: "Source-only inspect and plan through python -m dyro.bridge" + default_prompt: "Use $dyro-agent-bridge to inspect Dyro through the public Bridge process and stop if the host surface is unavailable." diff --git a/src/dyro/bridge/skill/manifest.json b/src/dyro/bridge/skill/manifest.json new file mode 100644 index 0000000..52f5125 --- /dev/null +++ b/src/dyro/bridge/skill/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "dyro-agent-bridge", + "installable": false, + "hosts": [], + "process": "python -m dyro.bridge", + "packaged_entry": false +} diff --git a/src/dyro/bridge/transport.py b/src/dyro/bridge/transport.py new file mode 100644 index 0000000..2fb2f22 --- /dev/null +++ b/src/dyro/bridge/transport.py @@ -0,0 +1,497 @@ +"""One-shot JSON transport. No CLI parser, no console entry point, no apply.""" + +from __future__ import annotations + +import json +from pathlib import Path +import secrets +from typing import BinaryIO, Callable + +from .. import __version__ as DYRO_VERSION +from ..continuation.resolution import ( + WorkspaceResolutionError, + resolve_workspace_readonly, +) +from ..errors import ValidationError +from ..read_limits import ReadBudget, ReadLimitCode, ReadLimitError +from .catalog import build_default_catalog, compact_catalog +from .constants import PLANNER_REVISIONS +from .models import Availability +from .observations import ( + BridgeObservationError, + default_read_budget, + gate_definitions, + list_lines_observation, + list_objectives_observation, + list_tasks_observation, + list_workspaces_observation, + objective_status_observation, + observe_workspace, + resolve_workspace_observation, +) +from .parse import MAX_REQUEST_BYTES, BoundedJSONError, load_bounded_json +from .plans import ( + objective_attention, + objective_explain, + objective_graph, + objective_plan, + objective_tick, +) +from .redaction import echo_request_id, presentation_message +from .schemas import operation_schema, validate_input + +SERVER_MAJOR = 1 +SERVER_MINOR = 0 +BRIDGE_VERSION = "1.0" +MAX_RESPONSE_BYTES = 1024 * 1024 +SAFE_INT_MAX = 9_007_199_254_740_991 +ENVELOPE_FIELDS = frozenset({"protocol", "request_id", "client", "operation", "input"}) +FORBIDDEN_FIELDS = frozenset( + {"actor", "approval", "confirmation", "command", "argv", "shell", "apply", "dry_run"} +) +MESSAGES = { + "INVALID_JSON": "The request is not one valid JSON object.", + "REQUEST_TOO_LARGE": "The request exceeds the transport size limit.", + "PROTOCOL_MAJOR_UNSUPPORTED": "The requested protocol major is unsupported.", + "PROTOCOL_MINOR_UNSUPPORTED": "The requested protocol minor is newer than this server.", + "SCHEMA_VALIDATION_FAILED": "The request envelope or operation input is invalid.", + "OPERATION_UNKNOWN": "The requested operation is not in the catalog.", + "OPERATION_UNAVAILABLE": "The requested operation is unavailable.", + "LOCAL_PROFILE_INVALID": "The local Dyro Profile is invalid.", + "REGISTRY_INVALID": "The global workspace registry cannot be trusted.", + "WORKSPACE_NOT_REGISTERED": "The requested workspace alias is not registered.", + "REGISTERED_ROOT_STALE": "The registered workspace root is no longer valid.", + "HOST_READ_PERMISSION_REQUIRED": "The host cannot read the selected resource.", + "AMBIGUOUS_WORKSPACE": "Multiple workspaces require an explicit selector.", + "WORKSPACE_NOT_FOUND": "No usable workspace was found.", + "RESOURCE_LIMIT_EXCEEDED": "A bounded workspace read budget was exhausted.", + "OBSERVATION_DEADLINE_EXCEEDED": "The bounded observation deadline elapsed.", + "OBJECTIVE_NOT_FOUND": "The requested Objective was not found.", + "TASK_NOT_FOUND": "The requested Task was not found.", + "INTERNAL_ERROR": "The request failed.", +} + +ExitAndPayload = tuple[int, dict[str, object]] + + +class _TransportError(Exception): + def __init__( + self, + code: str, + exit_code: int, + *, + requested_protocol: dict[str, int] | None = None, + operation: str | None = None, + schema_version: int | None = None, + planner_revision: str | None = None, + request_id: str | None = None, + ) -> None: + super().__init__(code) + self.code = code + self.exit_code = exit_code + self.requested_protocol = requested_protocol + self.operation = operation + self.schema_version = schema_version + self.planner_revision = planner_revision + self.request_id = request_id + + +def handle_request( + raw: bytes, + *, + cwd: Path, + exposure: str = "public", + clock: Callable | None = None, + platform: str | None = None, +) -> ExitAndPayload: + catalog = build_default_catalog(platform=platform) + try: + payload = _dispatch(raw, cwd=cwd, exposure=exposure, clock=clock, catalog=catalog) + return 0, payload + except _TransportError as exc: + return exc.exit_code, _error_payload(exc, catalog) + + +def serve_once( + stdin: BinaryIO, + stdout: BinaryIO, + *, + cwd: Path, + exposure: str = "public", + platform: str | None = None, +) -> int: + raw = stdin.read(MAX_REQUEST_BYTES + 1) + try: + stdin.close() + except OSError: + pass + exit_code, payload = handle_request( + raw, cwd=cwd, exposure=exposure, platform=platform + ) + encoded = _encode_response(payload) + try: + stdout.write(encoded) + stdout.flush() + except BrokenPipeError: + return 5 + except OSError as exc: + if getattr(exc, "errno", None) == 32: + return 5 + raise + return exit_code + + +def _dispatch( + raw: bytes, + *, + cwd: Path, + exposure: str, + clock: Callable | None, + catalog, +) -> dict[str, object]: + try: + parsed = load_bounded_json(raw) + except BoundedJSONError as exc: + raise _TransportError( + "REQUEST_TOO_LARGE" if exc.code == "REQUEST_TOO_LARGE" else "INVALID_JSON", + 2, + ) from exc + envelope = _envelope(parsed) + record = catalog.record(envelope["operation"]) + if record is None: + raise _TransportError( + "OPERATION_UNKNOWN", + 2, + requested_protocol=envelope["requested_protocol"], + operation=envelope["operation"], + request_id=envelope["request_id"], + ) + if not _callable(record.availability, exposure): + raise _TransportError( + "OPERATION_UNAVAILABLE", + 4, + requested_protocol=envelope["requested_protocol"], + operation=envelope["operation"], + schema_version=record.schema_version, + planner_revision=PLANNER_REVISIONS.get(record.id), + request_id=envelope["request_id"], + ) + schema = operation_schema(record.id, catalog=catalog) + try: + validate_input(schema["input"], envelope["input"]) + except ValidationError as exc: + raise _TransportError( + "SCHEMA_VALIDATION_FAILED", + 2, + requested_protocol=envelope["requested_protocol"], + operation=envelope["operation"], + schema_version=record.schema_version, + planner_revision=PLANNER_REVISIONS.get(record.id), + request_id=envelope["request_id"], + ) from exc + data, warnings, partial = _call( + record.id, envelope["input"], cwd=cwd, clock=clock, catalog=catalog + ) + if envelope["redacted"]: + warnings = [*warnings, {"code": "REQUEST_ID_REDACTED"}] + return { + "ok": True, + "meta": _meta( + catalog, + requested_protocol=envelope["requested_protocol"], + operation=record.id, + schema_version=record.schema_version, + planner_revision=PLANNER_REVISIONS.get(record.id), + request_id=envelope["request_id"], + partial=partial, + ), + "data": data, + "warnings": warnings, + } + + +def _envelope(parsed: object) -> dict[str, object]: + if not isinstance(parsed, dict): + raise _TransportError("INVALID_JSON", 2) + if FORBIDDEN_FIELDS & set(parsed): + raise _TransportError("SCHEMA_VALIDATION_FAILED", 2) + unknown = set(parsed) - ENVELOPE_FIELDS + if unknown: + raise _TransportError("SCHEMA_VALIDATION_FAILED", 2) + missing = {"protocol", "client", "operation", "input"} - set(parsed) + if missing: + raise _TransportError("SCHEMA_VALIDATION_FAILED", 2) + request_id, redacted = _optional_request_id(parsed.get("request_id")) + requested = _protocol(parsed.get("protocol")) + client = parsed.get("client") + if not isinstance(client, dict) or set(client) - {"name", "version"}: + raise _TransportError( + "SCHEMA_VALIDATION_FAILED", 2, requested_protocol=requested, request_id=request_id + ) + if not isinstance(client.get("name"), str) or not isinstance(client.get("version"), str): + raise _TransportError( + "SCHEMA_VALIDATION_FAILED", 2, requested_protocol=requested, request_id=request_id + ) + operation = parsed.get("operation") + if not isinstance(operation, str) or not operation: + raise _TransportError( + "SCHEMA_VALIDATION_FAILED", + 2, + requested_protocol=requested, + request_id=request_id, + ) + payload = parsed.get("input") + if not isinstance(payload, dict) or FORBIDDEN_FIELDS & set(payload): + raise _TransportError( + "SCHEMA_VALIDATION_FAILED", + 2, + requested_protocol=requested, + operation=operation, + request_id=request_id, + ) + start = payload.get("start") + if isinstance(start, str) and start.startswith("~"): + raise _TransportError( + "SCHEMA_VALIDATION_FAILED", + 2, + requested_protocol=requested, + operation=operation, + request_id=request_id, + ) + return { + "requested_protocol": requested, + "operation": operation, + "input": payload, + "request_id": request_id, + "redacted": redacted, + } + + +def _optional_request_id(value: object) -> tuple[str | None, bool]: + if value is None: + return None, False + echoed, redacted = echo_request_id(value) + return echoed, redacted + + +def _protocol(value: object) -> dict[str, int]: + if not isinstance(value, dict) or set(value) - {"major", "minor"}: + raise _TransportError("SCHEMA_VALIDATION_FAILED", 2) + major = value.get("major") + minor = value.get("minor") + if not _safe_int(major) or not _safe_int(minor): + raise _TransportError("SCHEMA_VALIDATION_FAILED", 2) + if major != SERVER_MAJOR: + raise _TransportError( + "PROTOCOL_MAJOR_UNSUPPORTED", + 2, + requested_protocol={"major": major, "minor": minor}, + ) + if minor > SERVER_MINOR: + raise _TransportError( + "PROTOCOL_MINOR_UNSUPPORTED", + 2, + requested_protocol={"major": major, "minor": minor}, + ) + return {"major": major, "minor": minor} + + +def _safe_int(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= SAFE_INT_MAX + + +def _callable(availability: Availability, exposure: str) -> bool: + if exposure == "public": + return availability is Availability.PUBLIC_AVAILABLE + if exposure == "testable": + return availability in { + Availability.PUBLIC_AVAILABLE, + Availability.IMPLEMENTED_TESTABLE, + } + raise _TransportError("INTERNAL_ERROR", 2) + + +def _call( + operation: str, + payload: dict[str, object], + *, + cwd: Path, + clock: Callable | None, + catalog, +) -> tuple[object, list[dict[str, str]], bool]: + budget = default_read_budget() + try: + data = _invoke( + operation, payload, cwd=cwd, clock=clock, budget=budget, catalog=catalog + ) + except BridgeObservationError as exc: + exit_code = 4 if exc.code == "OPERATION_UNAVAILABLE" else 3 + raise _TransportError(exc.code if exc.code in MESSAGES else "INTERNAL_ERROR", exit_code) from exc + except WorkspaceResolutionError as exc: + raise _TransportError(exc.code.value, 3) from exc + except ReadLimitError as exc: + code = ( + "OBSERVATION_DEADLINE_EXCEEDED" + if exc.code is ReadLimitCode.DEADLINE_EXCEEDED + else "RESOURCE_LIMIT_EXCEEDED" + ) + raise _TransportError(code, 3) from exc + except ValidationError as exc: + raise _TransportError("SCHEMA_VALIDATION_FAILED", 2) from exc + partial = bool(isinstance(data, dict) and data.get("partial")) + return data, [], partial + + +def _invoke( + operation: str, + payload: dict[str, object], + *, + cwd: Path, + clock: Callable | None, + budget: ReadBudget, + catalog, +) -> object: + if operation == "bridge.hello": + return { + "protocol": {"major": SERVER_MAJOR, "minor": SERVER_MINOR}, + "dyro_version": DYRO_VERSION, + "bridge_version": BRIDGE_VERSION, + } + if operation == "bridge.capabilities.compact": + return compact_catalog(catalog) + if operation == "bridge.operation.schema": + return operation_schema(str(payload["operation"]), catalog=catalog) + if operation == "workspace.list": + return list_workspaces_observation(budget=budget) + if operation == "workspace.resolve": + return resolve_workspace_observation( + start=payload.get("start"), + workspace=_alias(payload.get("workspace")), + cwd=cwd, + budget=budget, + ) + if operation == "workspace.observe": + return observe_workspace( + start=payload.get("start"), + workspace=_alias(payload.get("workspace")), + cwd=cwd, + budget=budget, + clock=clock, + ) + resolved = resolve_workspace_readonly( + start=payload.get("start"), + workspace=_alias(payload.get("workspace")), + cwd=cwd, + budget=budget, + ) + config = resolved.profile.config + if operation == "line.list": + return list_lines_observation(config) + if operation == "task.list": + return list_tasks_observation(config) + if operation == "task.gate_definitions.get": + return gate_definitions(config, str(payload["task_id"])) + if operation == "objective.list": + return list_objectives_observation(config) + if operation == "objective.status": + return objective_status_observation(config, str(payload["objective_id"])) + plan_builders = { + "objective.plan": objective_plan, + "objective.explain": objective_explain, + "objective.graph": objective_graph, + "objective.tick": objective_tick, + "objective.attention": objective_attention, + } + builder = plan_builders.get(operation) + if builder is None: + raise _TransportError("OPERATION_UNAVAILABLE", 4) + return builder( + config, + str(payload["objective_id"]), + profile_bytes=resolved.profile.profile_bytes, + clock=clock, + ) + + +def _alias(value: object) -> str | None: + if value is None: + return None + return str(value) + + +def _meta( + catalog, + *, + requested_protocol: dict[str, int] | None, + operation: str | None, + schema_version: int | None, + planner_revision: str | None, + request_id: str | None, + partial: bool = False, + truncated: bool = False, +) -> dict[str, object]: + return { + "server_protocol": {"major": SERVER_MAJOR, "minor": SERVER_MINOR}, + "requested_protocol": requested_protocol, + "dyro_version": DYRO_VERSION, + "bridge_version": BRIDGE_VERSION, + "operation": operation, + "operation_schema_version": schema_version, + "planner_revision": planner_revision, + "request_id": request_id, + "event_id": f"evt_{secrets.token_hex(8)}", + "capabilities_digest": catalog.digest, + "partial": partial, + "truncated": truncated, + } + + +def _error_payload(exc: _TransportError, catalog) -> dict[str, object]: + return { + "ok": False, + "meta": _meta( + catalog, + requested_protocol=exc.requested_protocol, + operation=exc.operation, + schema_version=exc.schema_version, + planner_revision=exc.planner_revision, + request_id=exc.request_id, + ), + "error": { + "code": exc.code, + "message": presentation_message(MESSAGES.get(exc.code, MESSAGES["INTERNAL_ERROR"])), + "retryable": False, + "details": {}, + "next_actions": [], + }, + } + + +def _encode_response(payload: dict[str, object]) -> bytes: + encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > MAX_RESPONSE_BYTES: + catalog = build_default_catalog() + encoded = json.dumps( + { + "ok": False, + "meta": _meta( + catalog, + requested_protocol=None, + operation=None, + schema_version=None, + planner_revision=None, + request_id=None, + truncated=True, + ), + "error": { + "code": "RESOURCE_LIMIT_EXCEEDED", + "message": MESSAGES["RESOURCE_LIMIT_EXCEEDED"], + "retryable": False, + "details": {}, + "next_actions": [], + }, + }, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return encoded + b"\n" diff --git a/src/dyro/capability/__init__.py b/src/dyro/capability/__init__.py index ced2fb5..20e4691 100644 --- a/src/dyro/capability/__init__.py +++ b/src/dyro/capability/__init__.py @@ -8,6 +8,7 @@ assert_capability_allows_write, card_forbids_execute, card_from_adapter, + card_trusted_usage, merge_capability_plane, parse_capability_tables, write_capability_denied, @@ -38,6 +39,7 @@ "card_forbids_execute", "card_from_adapter", "card_from_command", + "card_trusted_usage", "card_from_preset", "card_payload", "discover_unintegrated", diff --git a/src/dyro/capability/cards.py b/src/dyro/capability/cards.py index 6f2ca02..363b717 100644 --- a/src/dyro/capability/cards.py +++ b/src/dyro/capability/cards.py @@ -27,6 +27,13 @@ def write_capability_denied( return card_forbids_execute(capabilities.get(executor)) +def card_trusted_usage(capabilities: Mapping[str, object] | None, executor: str) -> bool: + """True only when a Card exists and declares verifiable provider usage.""" + if not capabilities or not executor: + return False + return bool(getattr(capabilities.get(executor), "trusted_usage", False)) + + def assert_capability_allows_write(config: object, executor: str) -> None: cards = getattr(config, "capabilities", None) if write_capability_denied(cards, executor): diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 339268c..16a272b 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -68,8 +68,10 @@ get_objective, list_objectives, pause_objective, + preview_objective_wave_budgets, reconcile_objective, remove_objective_target, + render_budget_preview_text, resume_objective, stop_objective, ) @@ -3351,30 +3353,34 @@ def _read_objective_plan( config, objective=record, budget=read_budget ) ) - return snapshot, build_continuation_plan(snapshot) + return record, snapshot, build_continuation_plan(snapshot) def cmd_objective_plan(args: argparse.Namespace) -> None: - _, plan = _read_objective_plan( - _config(args), + config = _config(args) + record, _, plan = _read_objective_plan( + config, args.id, read_budget=_control_plane_budget(args) if args.format == "json" else None, ) + preview = preview_objective_wave_budgets( + config, + objective=record.objective, + actions=plan.selected_actions, + now=datetime.now(timezone.utc), + ) if args.format == "json": - print( - json.dumps( - continuation_plan_payload(plan), - ensure_ascii=False, - sort_keys=True, - indent=2, - ) - ) + payload = continuation_plan_payload(plan) + payload["budget_preview"] = preview + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)) return print(render_plan_text(plan)) + for note in render_budget_preview_text(preview): + print(note) def cmd_objective_explain(args: argparse.Namespace) -> None: - _, plan = _read_objective_plan( + _, _, plan = _read_objective_plan( _config(args), args.id, read_budget=_control_plane_budget(args) if args.format == "json" else None, @@ -3393,7 +3399,7 @@ def cmd_objective_explain(args: argparse.Namespace) -> None: def cmd_objective_graph(args: argparse.Namespace) -> None: - snapshot, plan = _read_objective_plan( + _, snapshot, plan = _read_objective_plan( _config(args), args.id, read_budget=_control_plane_budget(args) if args.format == "json" else None, @@ -3443,6 +3449,12 @@ def cmd_objective_tick(args: argparse.Namespace) -> None: available_write, capabilities=getattr(config, "capabilities", None), ) + overlay["budget_preview"] = preview_objective_wave_budgets( + config, + objective=record.objective, + actions=tick.wave, + now=datetime.now(timezone.utc), + ) if args.format == "json": payload = scheduler_tick_payload(tick) payload.update(overlay) @@ -3456,6 +3468,8 @@ def cmd_objective_tick(args: argparse.Namespace) -> None: f"Harness: {binding['task_id']} -> {binding['executor']} " f"({binding['source']})" ) + for note in render_budget_preview_text(overlay["budget_preview"]): + print(note) def cmd_objective_attention(args: argparse.Namespace) -> None: @@ -4469,7 +4483,8 @@ def build_parser() -> argparse.ArgumentParser: ) objective_graph.set_defaults(func=cmd_objective_graph) objective_tick = objective_sub.add_parser( - "tick", help="预览下一组有界 Objective Action;不创建 intent 或执行任务" + "tick", + help="预览下一组有界 Objective Action 与预算;不创建 intent 或执行任务", ) objective_tick.add_argument("id") objective_tick.add_argument("--format", choices=("text", "json"), default="text") @@ -4569,7 +4584,10 @@ def build_parser() -> argparse.ArgumentParser: proof = sub.add_parser("proof", help="只读派生并核验交付 Proof(rebind,不是 replay)") proof_sub = proof.add_subparsers(dest="proof_command", required=True) - proof_list = proof_sub.add_parser("list", help="从当前工作区全量重派生 Proof") + proof_list = proof_sub.add_parser( + "list", + help="从当前工作区全量重派生 Proof(含 trigger_observation;--task 不含;--line 只含该线 Objective 的 trigger)", + ) proof_list.add_argument("--task") proof_list.add_argument("--objective") proof_list.add_argument("--line") diff --git a/src/dyro/config.py b/src/dyro/config.py index 9771417..0893560 100644 --- a/src/dyro/config.py +++ b/src/dyro/config.py @@ -73,6 +73,7 @@ class Config: policy: Policy recommended_tool: str = "" capabilities: dict[str, object] = field(default_factory=dict) + max_provider_usage: int | None = None @property def task_specs_dir(self) -> Path: @@ -196,6 +197,17 @@ def table(name: str) -> dict[str, Any]: recommended_tool = recommended_tool_raw.strip() if recommended_tool: validate_id(recommended_tool, "workspace.recommended_tool") + max_provider_usage_raw = workspace_raw.get("max_provider_usage") + if max_provider_usage_raw is None: + max_provider_usage = None + elif ( + isinstance(max_provider_usage_raw, bool) + or not isinstance(max_provider_usage_raw, int) + or max_provider_usage_raw < 1 + ): + raise ValidationError("workspace.max_provider_usage 必须是正整数") + else: + max_provider_usage = max_provider_usage_raw layout_raw = table("layout") layout = Layout( anchors=_relative( @@ -337,6 +349,7 @@ def table(name: str) -> dict[str, Any]: policy, recommended_tool, cards, + max_provider_usage, ) diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index 9a70459..419f342 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -18,7 +18,7 @@ import time from typing import Any -from ..config import load, validate_id +from ..config import load from ..hub import WorkspaceRecord, WorkspaceRegistry from .overview import ConsoleOverviewError, ConsoleOverviewService @@ -56,6 +56,7 @@ def _unavailable_summary(alias: str, code: str) -> dict[str, object]: "command": f"dyro --workspace {alias} doctor", }, "snapshot_sha256": "", + "proof_inspection": "not_inspected", } @@ -207,26 +208,9 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None: def _isolated_workspace( service: ConsoleOverviewService, alias: str ) -> dict[str, object]: - try: - alias = validate_id(alias, "工作区别名") - except Exception: - raise ConsoleOverviewError("WORKSPACE_ALIAS_INVALID") from None - registry = service._load_registry() - try: - record = next(item for item in registry.workspaces if item.name == alias) - except StopIteration: - raise ConsoleOverviewError("WORKSPACE_NOT_FOUND") from None - isolated_registry = WorkspaceRegistry( - default=record.name if record.name == registry.default else "", - workspaces=(record,), - ) - summaries, warnings = _isolated_summaries( - isolated_registry, - total_timeout=_OVERVIEW_TIMEOUT_SECONDS, - ) - if not summaries: - raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") - return service._envelope({"workspace": summaries[0]}, warnings) + # Run in this exec worker so hung git stays in the parent's killpg + # group. Nested summary spawn only returns the count card. + return service.workspace(alias) def _secret_from_environment() -> bytes: @@ -324,6 +308,8 @@ def main(argv: list[str] | None = None) -> int: alias = request.get("alias") if not isinstance(alias, str): raise ConsoleOverviewError("WORKSPACE_ALIAS_INVALID") + # Inspect is its own exec-worker request. Keep git descendants in + # this process group so the parent's 8s killpg can reap them. payload = service.inspect_proofs(alias) else: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index 396db97..df28316 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -22,18 +22,18 @@ class ConsoleAsset: ASSET_MANIFEST = { "index.html": ( "text/html; charset=utf-8", - "4018afa6a9bfa3b694fa7c1c8cbc2fec0b31a9594691c5fe9ca123ad519ea389", - 3040, + "76a17597706397bad43a0ba84a121e625abb70e30f2f2cf42f61521308cb44f2", + 3138, ), "app.js": ( "text/javascript; charset=utf-8", - "bfc6909d39325d2b5ab2f59357889cbc42f1c772e754b79b1be82947085b033e", - 16993, + "7e546606308a9ea85169c2b938b54d81b20018614ca96980615f123fc6675167", + 22848, ), "styles.css": ( "text/css; charset=utf-8", - "bd351f50ab998530cc08ab2a0f36ba2d8f9054a17ac8b1269976235db8bca0d8", - 10509, + "1b295330e8a23907a2b48b00778a9ab5a59e4b6af10e691cf6d3113c8d1d2366", + 11333, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index e32d96c..5f0fc96 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -1,9 +1,68 @@ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; const TOKEN_KEY = "dyro.console.bearer"; -const state = { bearer: "", etags: new Map(), timer: null, focus: "", partial: false }; +const state = { bearer: "", etags: new Map(), timer: null, focus: "", partial: false, surfaces: [] }; const HEALTH_LABELS = { healthy: "健康", degraded: "需关注", unavailable: "不可用" }; const FRESHNESS_LABELS = { fresh: "新鲜", partial: "部分可用", stale: "待更新" }; const AVAILABILITY_LABELS = { available: "可用", unavailable: "不可用" }; +const LINE_KIND_LABELS = { line: "开发线", hotfix: "热修线" }; +const TASK_STATUS_LABELS = { + backlog: "待办", + assigned: "已分配", + in_progress: "进行中", + waiting_answer: "待回答", + review: "复核中", + review_pending_signoff: "待签核", + done: "已完成", + failed: "失败", +}; +const TASK_STATUS_ORDER = [ + "backlog", + "assigned", + "in_progress", + "waiting_answer", + "review", + "review_pending_signoff", + "done", + "failed", +]; +const OPERATOR_STATE_LABELS = { + active: "进行中", + paused: "已暂停", + completed: "已完成", + repair_required: "需要修复", +}; +const PROOF_INSPECTION_LABELS = { not_inspected: "摘要未核验", inspected: "已独立检查" }; +const PROOF_KIND_LABELS = { + gate_log: "门禁日志", + review_verdict: "复核结论", + signoff: "外部签核", + integration_heads: "集成 HEAD", + action_receipt: "动作回执", + trigger_observation: "触发观察", +}; +const PROOF_STATUS_LABELS = { + live: "检查仍有效", + decayed: "已衰减", + inconclusive: "无法判定", + revoked: "已撤销", +}; +const PROOF_LIVE_LABELS = { + review_verdict: "复核仍绑当前 HEAD", + signoff: "签核仍绑当前 HEAD", + trigger_observation: "探测窗口未到期", + gate_log: "门禁字节仍匹配", + integration_heads: "集成 HEAD 仍在祖先链", + action_receipt: "动作回执仍有效", +}; +const PROOF_DECAY_LABELS = { + review_acceptance: "复核绑定已失效", + external_signoff: "外部签核已失效", + dependency_integrated: "依赖集成已失效", + gate_bytes: "门禁字节已失效", + next_probe_at: "下次探测已到期", + still_bound: "谓词仍成立", + predicate_inconclusive: "谓词无法判定", +}; const ERROR_LABELS = { LOCAL_READ_UNAVAILABLE: "本地状态暂时不可读取", OVERVIEW_UNAVAILABLE: "工作区概览暂时不可读取", @@ -43,6 +102,26 @@ function displayLabel(value, labels) { return labels[raw] || raw || "未提供"; } +function taskIntegrationLabel(state) { + // 摘要路径只能是 not_inspected。写成「已集成」会把未核验摘要伪装成 merge 放行。 + return text(state) === "not_inspected" ? "摘要未核验集成" : "未提供"; +} + +function describeTask(task) { + const status = displayLabel(task && task.status, TASK_STATUS_LABELS); + const integration = taskIntegrationLabel(task && task.integration_state); + const blocked = Array.isArray(task && task.blocked_on) && task.blocked_on.length + ? `阻塞于 ${task.blocked_on.map((item) => text(item)).filter(Boolean).join("、")}` + : ""; + return blocked ? `${status} · ${integration} · ${blocked}` : `${status} · ${integration}`; +} + +function proofStatusLabel(kind, status) { + const raw = text(status); + if (raw === "live") return displayLabel(kind, PROOF_LIVE_LABELS); + return displayLabel(raw, PROOF_STATUS_LABELS); +} + function userError(value) { const code = text(value); return ERROR_LABELS[code] || "本地状态暂时不可读取"; @@ -192,6 +271,27 @@ function attentionLevel(summary) { return ""; } +function readableWorkspaceCount(workspaces) { + if (!Array.isArray(workspaces)) return 0; + return workspaces.filter((summary) => text(summary.availability) === "available").length; +} + +function renderTaskStatusCounts(counts, workspaces) { + const root = $("task-status-counts"); + if (!root) return; + root.replaceChildren(); + const readable = readableWorkspaceCount(workspaces); + for (const key of TASK_STATUS_ORDER) { + const card = element("div"); + card.className = "count task-count"; + if (key === "failed" && readable && count(counts && counts[key])) card.dataset.level = "danger"; + if (key === "in_progress" && readable && count(counts && counts[key])) card.dataset.level = "warning"; + const value = readable ? String(count(counts && counts[key])) : "—"; + card.append(element("strong", value), element("span", displayLabel(key, TASK_STATUS_LABELS))); + root.append(card); + } +} + function renderCounts(attention) { const root = $("attention-counts"); root.replaceChildren(); @@ -271,6 +371,7 @@ function renderOverview(payload) { : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; $("captured-at").textContent = text(payload.captured_at) ? `采样于 ${new Date(text(payload.captured_at)).toLocaleString("zh-CN")}` : ""; renderCounts(data.attention_counts || {}); + renderTaskStatusCounts(data.task_status_counts || {}, data.workspaces); renderPrimaryAction(data.workspaces); const list = $("workspace-list"); list.replaceChildren(); @@ -283,13 +384,57 @@ function renderOverview(payload) { for (const summary of data.workspaces) list.append(renderWorkspaceCard(summary)); } +function hasSurface(name) { + return state.surfaces.includes(name); +} + +function renderInventoryList(title, items, describe) { + const section = element("div"); + section.className = "inventory"; + section.append(element("h3", title)); + if (!items.length) { + section.append(element("p", "没有可展示的项目。")); + return section; + } + const list = element("ul"); + for (const item of items) list.append(element("li", describe(item))); + section.append(list); + return section; +} + +function renderInventory(data) { + const root = element("div"); + root.className = "workspace-inventory"; + const lines = Array.isArray(data && data.lines) ? data.lines : []; + const tasks = Array.isArray(data && data.tasks) ? data.tasks : []; + const objectives = Array.isArray(data && data.objectives) ? data.objectives : []; + root.append( + renderInventoryList("开发线", lines, (line) => { + const kind = displayLabel(line.kind, LINE_KIND_LABELS); + const branch = text(line.branch) || "未提供"; + return `${text(line.id)} · ${kind} · ${branch}`; + }), + renderInventoryList("任务", tasks, (task) => { + const title = text(task.title) || text(task.id) || "未命名任务"; + return `${title} · ${describeTask(task)}`; + }), + renderInventoryList("目标", objectives, (objective) => { + const title = text(objective.title) || text(objective.id) || "未命名目标"; + const state = displayLabel(objective.operator_state, OPERATOR_STATE_LABELS); + return `${title} · ${state}`; + }), + ); + return root; +} + function renderProofInspect(inspect) { const inspection = text(inspect && inspect.proof_inspection); const section = element("div"); section.className = "proof-inspect"; - section.append(element("h3", inspection === "inspected" ? "Proof 已检查" : "Proof 未检查")); + section.append(element("h3", inspection === "inspected" ? "独立检查 · 已检查" : "独立检查 · 未完成")); + section.append(element("p", "独立检查只读投影,不是 task merge 放行。")); if (inspection !== "inspected") { - section.append(element("p", "摘要保持未检查。独立检查失败时不会把摘要标成已检查。")); + section.append(element("p", "摘要保持未核验。独立检查未完成时不会把摘要标成已检查。")); return section; } const proofs = Array.isArray(inspect.proofs) ? inspect.proofs : []; @@ -299,10 +444,10 @@ function renderProofInspect(inspect) { } const list = element("ul"); for (const proof of proofs) { - const kind = text(proof.kind); - const status = text(proof.status); + const kind = displayLabel(proof.kind, PROOF_KIND_LABELS); + const status = proofStatusLabel(proof.kind, proof.status); const reason = text(proof.decay_reason); - list.append(element("li", reason ? `${kind} · ${status} · ${reason}` : `${kind} · ${status}`)); + list.append(element("li", reason ? `${kind} · ${status} · ${displayLabel(reason, PROOF_DECAY_LABELS)}` : `${kind} · ${status}`)); } section.append(list); const decayed = []; @@ -316,6 +461,9 @@ function renderProofInspect(inspect) { } async function loadProofInspect(alias) { + if (!hasSurface("proofs")) { + return renderProofInspect({ proof_inspection: "not_inspected", proofs: [], objectives: [] }); + } try { const payload = await request(`/api/v1/workspaces/${encodeURIComponent(alias)}/proofs`, `proofs:${alias}`); if (payload && payload.data) return renderProofInspect(payload.data); @@ -350,8 +498,10 @@ async function loadWorkspace(alias, silent = false) { definition("任务总数", workspaceCount(summary, "task_count")), definition("开发线", workspaceCount(summary, "line_count")), definition("目标", workspaceCount(summary, "objective_count")), + definition("摘要 Proof", displayLabel(summary.proof_inspection, PROOF_INSPECTION_LABELS)), ); content.replaceChildren(grid); + content.append(renderInventory(payload.data)); const command = text(summary.recommendation && summary.recommendation.command); if (command) content.append(commandRow(command)); content.append(await loadProofInspect(alias)); @@ -426,7 +576,13 @@ async function start() { else state.bearer = sessionStorage.getItem(TOKEN_KEY) || ""; if (!state.bearer) throw new Error("SESSION_EXPIRED"); const meta = await request("/api/v1/meta", "meta"); - if (meta && !state.focus) state.focus = text(meta.data && meta.data.initial_workspace); + if (meta) { + const surfaces = meta.data && (meta.data.surfaces || meta.data.capabilities); + state.surfaces = Array.isArray(surfaces) + ? surfaces.filter((item) => typeof item === "string") + : []; + if (!state.focus) state.focus = text(meta.data && meta.data.initial_workspace); + } await refresh(); scheduleRefresh(); } catch (error) { diff --git a/src/dyro/console/assets/index.html b/src/dyro/console/assets/index.html index 118de9f..ccb268d 100644 --- a/src/dyro/console/assets/index.html +++ b/src/dyro/console/assets/index.html @@ -30,6 +30,7 @@

正在读取工程状态

+

下一步

diff --git a/src/dyro/console/assets/styles.css b/src/dyro/console/assets/styles.css index 77bae94..386750c 100644 --- a/src/dyro/console/assets/styles.css +++ b/src/dyro/console/assets/styles.css @@ -134,6 +134,14 @@ h2 { font-size: clamp(1.8rem, 3.2vw, 2.85rem); letter-spacing: -.045em; margin-b .count[data-level="warning"] strong { color: var(--warning); } .count[data-level="success"] strong { color: var(--success); } +.task-counts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0 0 1.8rem; +} +.task-count { min-height: 3.6rem; padding: .15rem 1rem; } +.task-count strong { font-size: clamp(1.2rem, 2.2vw, 1.7rem); } + .next-step { align-items: center; background: #07182b; @@ -211,6 +219,12 @@ code { .detail-grid div { border-top: 1px solid var(--border); padding-top: .75rem; } .detail-grid dt { color: var(--subtle); font-size: .8rem; } .detail-grid dd { font-weight: 750; margin: .25rem 0 0; } +.workspace-inventory { border-top: 1px solid var(--border); margin-top: 1.15rem; padding-top: .25rem; } +.inventory { margin-top: .9rem; } +.inventory h3 { font-size: .95rem; margin: 0 0 .5rem; } +.inventory p, .inventory ul { color: var(--subtle); font-size: .86rem; margin: 0; } +.inventory ul { padding-left: 1.1rem; } +.inventory li { margin: .2rem 0; } .proof-inspect { border-top: 1px solid var(--border); margin-top: 1.15rem; padding-top: 1rem; } .proof-inspect h3 { font-size: .95rem; margin: 0 0 .5rem; } .proof-inspect p, .proof-inspect ul { color: var(--subtle); font-size: .86rem; margin: 0; } @@ -236,11 +250,13 @@ footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .8 .command-center-top > div { max-width: 24rem; } .counts { align-self: center; grid-column: 2; grid-row: 1; margin: 0; } .count { min-height: 5.7rem; padding: .2rem 1rem; } + .task-counts { grid-column: 1 / -1; margin: 0; } .next-step { grid-column: 1 / -1; } } @media (max-width: 900px) { .counts { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 1.2rem; } + .task-counts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 1rem; } .count:nth-child(4) { border-left: 0; padding-left: 0; } .workspace-column-headings { display: none; } .workspace-row { gap: 1rem; grid-template-columns: minmax(10rem, 1.6fr) minmax(5.5rem, .7fr) minmax(5.5rem, .7fr) 3.5rem 3.5rem; } @@ -258,6 +274,7 @@ footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .8 .command-center-top, .next-step, .section-heading { align-items: flex-start; flex-direction: column; } .command-center-top .secondary { align-self: stretch; } .counts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 1.8rem 0 1.25rem; row-gap: 1rem; } + .task-counts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 1.25rem; row-gap: 1rem; } .count, .count:nth-child(4) { border-left: 1px solid var(--border); padding: .2rem 1rem; } .count:nth-child(odd) { border-left: 0; padding-left: 0; } .next-step { padding: 1rem; } diff --git a/src/dyro/console/inspection.py b/src/dyro/console/inspection.py index dbc8915..7b4fc8b 100644 --- a/src/dyro/console/inspection.py +++ b/src/dyro/console/inspection.py @@ -20,7 +20,7 @@ from ..canonical import canonical_json_bytes from ..hub import registry_home from .overview import ConsoleOverviewError -from .redaction import REDACTED, safe_id, safe_sha256, safe_title +from .redaction import REDACTED, safe_branch, safe_id, safe_sha256, safe_title _CURSOR_SECRET_ENV = "DYRO_CONSOLE_CURSOR_SECRET" @@ -31,6 +31,44 @@ _ATTENTION_KINDS = frozenset( {"repair_required", "needs_user", "ready", "paused", "waiting"} ) +_LINE_KEYS = frozenset({"id", "kind", "branch", "base", "repository_count"}) +_TASK_KEYS = frozenset( + { + "id", + "title", + "line", + "status", + "risk", + "depends_on", + "blocked_on", + "conflict_group", + "executor", + "reviewer", + "integration_state", + "external_claim_active", + } +) +_OBJECTIVE_KEYS = frozenset( + { + "id", + "title", + "line", + "revision", + "operator_state", + "derived_result", + "requested_mode", + "operations", + "scope_count", + "budget", + "selected_actions", + "blocked_actions", + "attention", + "contract_sha256", + "scope_sha256", + "event_sha256", + } +) +_ACTION_KEYS = frozenset({"kind", "subject_id", "reason"}) _SUMMARY_KEYS = frozenset( { "alias", @@ -48,6 +86,7 @@ "attention_counts", "recommendation", "snapshot_sha256", + "proof_inspection", } ) @@ -273,12 +312,13 @@ def _validate_data( if expected_operation == "inspect_proofs": cls._validate_inspect(data) return - if set(data) == {"workspace"}: - if expected_operation == "overview": + if expected_operation == "workspace": + if set(data) != {"workspace", "lines", "tasks", "objectives"}: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") cls._validate_summary(data["workspace"]) + cls._validate_inventory(data) return - if expected_operation == "workspace": + if set(data) == {"workspace"}: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") expected = { "default_workspace", @@ -286,6 +326,7 @@ def _validate_data( "workspaces", "next_cursor", "attention_counts", + "task_status_counts", "highest_priority", } if set(data) != expected: @@ -306,6 +347,7 @@ def _validate_data( ): raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") cls._validate_attention_counts(data["attention_counts"]) + cls._validate_status_counts(data["task_status_counts"]) highest = data["highest_priority"] if highest is not None: if not isinstance(highest, dict) or set(highest) != {"alias", "kind", "reason"}: @@ -398,12 +440,7 @@ def _validate_summary(cls, value: object) -> None: ): if not cls._count(value.get(key)): raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") - statuses = value.get("task_status_counts") - if not isinstance(statuses, dict): - raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") - for status, count in statuses.items(): - if not cls._safe_code(status) or not cls._count(count): - raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + cls._validate_status_counts(value.get("task_status_counts")) cls._validate_attention_counts(value.get("attention_counts")) recommendation = value.get("recommendation") if not isinstance(recommendation, dict) or set(recommendation) != {"reason", "command"}: @@ -415,6 +452,123 @@ def _validate_summary(cls, value: object) -> None: digest = value.get("snapshot_sha256") if digest != "" and safe_sha256(digest) != digest: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if value.get("proof_inspection") != "not_inspected": + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_inventory(cls, data: dict[str, object]) -> None: + cls._validate_lines(data["lines"]) + cls._validate_tasks(data["tasks"]) + cls._validate_objectives(data["objectives"]) + + @classmethod + def _validate_lines(cls, value: object) -> None: + if not isinstance(value, list) or len(value) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for item in value: + if not isinstance(item, dict) or set(item) != _LINE_KEYS: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_alias(item.get("id")) + or not cls._safe_code(item.get("kind")) + or safe_branch(item.get("branch")) != item.get("branch") + or safe_branch(item.get("base")) != item.get("base") + or not cls._count(item.get("repository_count")) + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_tasks(cls, value: object) -> None: + if not isinstance(value, list) or len(value) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for item in value: + if not isinstance(item, dict) or set(item) != _TASK_KEYS: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + title = item.get("title") + if title != REDACTED and safe_title(title) != title: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_alias(item.get("id")) + or not cls._safe_code(item.get("line")) + or not cls._safe_code(item.get("status")) + or not cls._safe_code(item.get("risk")) + or not cls._id_list(item.get("depends_on")) + or not cls._id_list(item.get("blocked_on")) + or not cls._optional_id(item.get("conflict_group")) + or not cls._safe_code(item.get("executor")) + or not cls._safe_code(item.get("reviewer")) + or item.get("integration_state") != "not_inspected" + or type(item.get("external_claim_active")) is not bool + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_objectives(cls, value: object) -> None: + if not isinstance(value, list) or len(value) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for item in value: + if not isinstance(item, dict) or set(item) != _OBJECTIVE_KEYS: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + title = item.get("title") + if title != REDACTED and safe_title(title) != title: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_alias(item.get("id")) + or not cls._safe_code(item.get("line")) + or not cls._count(item.get("revision")) + or not cls._safe_code(item.get("operator_state")) + or not cls._safe_code(item.get("derived_result")) + or not cls._safe_code(item.get("requested_mode")) + or not cls._id_list(item.get("operations")) + or not cls._count(item.get("scope_count")) + or not cls._budget(item.get("budget")) + or not cls._action_list(item.get("selected_actions"), attention=False) + or not cls._action_list(item.get("blocked_actions"), attention=False) + or not cls._action_list(item.get("attention"), attention=True) + or safe_sha256(item.get("contract_sha256")) != item.get("contract_sha256") + or safe_sha256(item.get("scope_sha256")) != item.get("scope_sha256") + or safe_sha256(item.get("event_sha256")) != item.get("event_sha256") + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _action_list(cls, value: object, *, attention: bool) -> bool: + if not isinstance(value, list) or len(value) > 1000: + return False + for item in value: + if not isinstance(item, dict) or set(item) != _ACTION_KEYS: + return False + if item.get("reason") == "PROOF_DECAYED": + return False + if attention and item.get("kind") not in _ATTENTION_KINDS: + return False + if ( + not cls._safe_code(item.get("kind")) + or not cls._safe_code(item.get("subject_id")) + or not cls._safe_code(item.get("reason")) + ): + return False + return True + + @classmethod + def _id_list(cls, value: object) -> bool: + if not isinstance(value, list) or len(value) > 1000: + return False + return all(cls._safe_code(item) for item in value) + + @staticmethod + def _optional_id(value: object) -> bool: + return value == "" or IsolatedOverviewService._safe_code(value) + + @staticmethod + def _budget(value: object) -> bool: + if not isinstance(value, dict) or len(value) > 32: + return False + return all( + IsolatedOverviewService._safe_code(key) + and IsolatedOverviewService._count(count) + for key, count in value.items() + ) @staticmethod def _count(value: object) -> bool: @@ -446,3 +600,11 @@ def _validate_attention_counts(value: object) -> None: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") if not all(IsolatedOverviewService._count(item) for item in value.values()): raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_status_counts(cls, value: object) -> None: + if not isinstance(value, dict) or len(value) > 32: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for status, count in value.items(): + if not cls._safe_code(status) or not cls._count(count): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index f2fab18..a3f4e1d 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -80,6 +80,39 @@ def _safe_list(value: object) -> list[dict[str, object]]: return [dict(item) for item in value if isinstance(item, dict)] +def _empty_inventory() -> dict[str, list[dict[str, object]]]: + return {"lines": [], "tasks": [], "objectives": []} + + +def _without_proof_decay(items: object) -> list[dict[str, object]]: + return [ + dict(item) + for item in _safe_list(items) + if _safe_code(item.get("reason")) != "PROOF_DECAYED" + ] + + +def _inventory_from_envelope(data: dict[str, object]) -> dict[str, list[dict[str, object]]]: + """Project already-captured lists. Never add Proofs or rebind integration.""" + tasks: list[dict[str, object]] = [] + for item in _safe_list(data.get("tasks")): + task = dict(item) + task["integration_state"] = "not_inspected" + tasks.append(task) + objectives: list[dict[str, object]] = [] + for item in _safe_list(data.get("objectives")): + objective = dict(item) + objective["attention"] = _without_proof_decay(item.get("attention")) + objective["selected_actions"] = _without_proof_decay(item.get("selected_actions")) + objective["blocked_actions"] = _without_proof_decay(item.get("blocked_actions")) + objectives.append(objective) + return { + "lines": [dict(item) for item in _safe_list(data.get("lines"))], + "tasks": tasks, + "objectives": objectives, + } + + class ConsoleOverviewService: """Read and project registered workspaces without changing their state.""" @@ -149,12 +182,17 @@ def page( "workspaces": items, "next_cursor": next_cursor, "attention_counts": attention_counts, + "task_status_counts": self._task_status_counts(summaries), "highest_priority": self._highest_priority(summaries), } return self._envelope(data, warning_codes) def workspace(self, alias: str) -> dict[str, object]: - """Return one registered workspace summary from the shared projection.""" + """Return one summary card plus already-captured inventory. + + Inventory comes from the same summary snapshot. It is not an inspect, + so Proofs stay out and task integration stays unread. + """ try: alias = validate_id(alias, "工作区别名") except ValidationError: @@ -164,10 +202,10 @@ def workspace(self, alias: str) -> dict[str, object]: record = next(item for item in registry.workspaces if item.name == alias) except StopIteration: raise ConsoleOverviewError("WORKSPACE_NOT_FOUND") from None - summary, warning_codes = self._summary( + summary, warning_codes, inventory = self._capture( record.name, record.root, record.name == registry.default ) - return self._envelope({"workspace": summary}, warning_codes) + return self._envelope({"workspace": summary, **inventory}, warning_codes) def inspect_proofs(self, alias: str) -> dict[str, object]: """Independent Proof inspect. Must not use the summary snapshot_loader.""" @@ -246,7 +284,9 @@ def _summaries( summaries: list[dict[str, object]] = [] warnings: set[str] = set() for record in registry.workspaces: - summary, codes = self._summary(record.name, record.root, record.name == registry.default) + summary, codes = self._summary( + record.name, record.root, record.name == registry.default + ) summaries.append(summary) warnings.update(codes) summaries.sort(key=self._summary_sort_key) @@ -269,6 +309,12 @@ def _load_summaries( def _summary( self, alias: str, root: Path, is_default: bool ) -> tuple[dict[str, object], set[str]]: + summary, warnings, _inventory = self._capture(alias, root, is_default) + return summary, warnings + + def _capture( + self, alias: str, root: Path, is_default: bool + ) -> tuple[dict[str, object], set[str], dict[str, list[dict[str, object]]]]: safe_alias = _safe_code(alias) try: config = self._config_loader(root) @@ -295,8 +341,10 @@ def _summary( "command": f"dyro --workspace {safe_alias} doctor", }, "snapshot_sha256": "", + "proof_inspection": "not_inspected", }, {"WORKSPACE_UNAVAILABLE"}, + _empty_inventory(), ) data = _safe_mapping(envelope.get("data")) @@ -335,8 +383,9 @@ def _summary( "attention_counts": attention["counts"], "recommendation": self._recommendation(safe_alias, attention["items"]), "snapshot_sha256": str(envelope.get("snapshot_sha256", "")), + "proof_inspection": "not_inspected", } - return summary, warning_codes + return summary, warning_codes, _inventory_from_envelope(data) @staticmethod def _empty_attention_counts() -> dict[str, int]: @@ -351,7 +400,8 @@ def _workspace_attention( objective_id = _safe_code(objective.get("id")) for raw in _safe_list(objective.get("attention")): kind = _safe_code(raw.get("kind")) - if kind not in _ATTENTION_PRIORITY: + reason = _safe_code(raw.get("reason")) + if kind not in _ATTENTION_PRIORITY or reason == "PROOF_DECAYED": continue counts[kind] += 1 items.append( @@ -359,7 +409,7 @@ def _workspace_attention( "objective_id": objective_id, "kind": kind, "subject_id": _safe_code(raw.get("subject_id")), - "reason": _safe_code(raw.get("reason")), + "reason": reason, } ) items.sort( @@ -397,6 +447,23 @@ def _attention_counts(self, summaries: list[dict[str, object]]) -> dict[str, int counts[kind] += value return counts + def _task_status_counts(self, summaries: list[dict[str, object]]) -> dict[str, int]: + """Sum task statuses from readable workspaces only. + + Unavailable cards keep empty maps. Counting them as 0 would pretend + unread workspaces have no work. + """ + counts: dict[str, int] = {} + for summary in summaries: + if summary.get("availability") != "available": + continue + for status, value in _safe_mapping(summary.get("task_status_counts")).items(): + code = _safe_code(status) + if code == "REDACTED" or type(value) is not int or value < 0: + continue + counts[code] = counts.get(code, 0) + value + return dict(sorted(counts.items())) + def _highest_priority(self, summaries: list[dict[str, object]]) -> dict[str, str] | None: candidates: list[tuple[int, str, dict[str, str]]] = [] for summary in summaries: diff --git a/src/dyro/console/server.py b/src/dyro/console/server.py index c1d5dee..df92836 100644 --- a/src/dyro/console/server.py +++ b/src/dyro/console/server.py @@ -304,7 +304,12 @@ def _dispatch(self) -> None: "schema_version": 1, "data": { "version": __version__, - "capabilities": ["overview"] if self.console.overview_service else [], + "surfaces": ( + ["overview", "proofs"] if self.console.overview_service else [] + ), + "capabilities": ( + ["overview", "proofs"] if self.console.overview_service else [] + ), "initial_workspace": self.console.initial_workspace, "session_expires_at": session.expires_at.isoformat(), }, diff --git a/src/dyro/continuation/budgets.py b/src/dyro/continuation/budgets.py index 8283b50..ca92e6d 100644 --- a/src/dyro/continuation/budgets.py +++ b/src/dyro/continuation/budgets.py @@ -139,7 +139,7 @@ class BudgetUsage: consecutive_failures: int = 0 active_parallel: int = 0 provider_usage: int = 0 - provider_usage_trusted: bool = True + provider_usage_trusted: bool = False no_progress_cycles: int = 0 attempts_by_task: tuple[tuple[str, int], ...] = () diff --git a/src/dyro/continuation/store.py b/src/dyro/continuation/store.py index 972d948..0504dd9 100644 --- a/src/dyro/continuation/store.py +++ b/src/dyro/continuation/store.py @@ -13,8 +13,9 @@ import json import os from pathlib import Path -from typing import Iterable, Iterator +from typing import Iterable, Iterator, Mapping, Sequence +from ..capability.cards import card_trusted_usage from ..config import Config, validate_id from ..errors import DyroError, ValidationError from ..graph import build_task_graph, validate_task_graph @@ -58,7 +59,7 @@ BudgetUsage, decide_budget, ) -from .models import ActionKind, Objective, Operation, RequestedMode +from .models import ActionKind, Objective, Operation, PlannedAction, RequestedMode from .objective_storage import ( OBJECTIVE_STORE_SCHEMA_VERSION, OPERATOR_STATES, @@ -700,7 +701,8 @@ def _budget_usage( when its terminal receipt is still missing. Only not-started intents are treated as reservations by the caller. ``uncertain`` deliberately counts as a failure: it may have produced a side effect and must never make the - next Action look safer than it is. + next Action look safer than it is. Empty usage is vacuously trusted; + any started Action still lacks a Card-backed meter, so it stays untrusted. """ selected = tuple( record @@ -744,6 +746,8 @@ def _budget_usage( for record in started if record.receipt is None ), + provider_usage=0, + provider_usage_trusted=not started, attempts_by_task=tuple(sorted(attempts.items())), ) @@ -758,7 +762,11 @@ def _reserved_budgets(records: Iterable[ActionRecord]) -> tuple[BudgetReservatio def _all_action_records_unlocked(config: Config) -> tuple[ActionRecord, ...]: - """Read every durable Action Journal while the workspace Objective lock is held.""" + """Read every durable Action Journal. + + Reservation holds the workspace Objective lock. Preview may call this + without that lock and must tolerate a dirty read. + """ actions: list[ActionRecord] = [] for stored in _list_objectives_unlocked(config, recover=False): with open_objective_directory(config, stored.objective.id) as directory: @@ -766,19 +774,121 @@ def _all_action_records_unlocked(config: Config) -> tuple[ActionRecord, ...]: return tuple(actions) -def _budget_request(intent: ActionIntent) -> BudgetRequest: - """Fix the conservative charge for each supported supervised operation.""" - if intent.operation is ActionKind.EXECUTE_TASK: +def _trusted_usage_for_subject(config: Config, task_id: str) -> bool: + """Card.trusted_usage only. Missing task or Card stays untrusted.""" + try: + task = next(item for item in list_tasks(config) if item.id == task_id) + except (StopIteration, DyroError, OSError, ValidationError): + return False + return card_trusted_usage(getattr(config, "capabilities", None), task.executor) + + +def _workspace_budget_caps(config: Config) -> BudgetCaps: + """Workspace metering only. Missing cap means untrusted usage will not hard-stop.""" + return BudgetCaps(max_provider_usage=getattr(config, "max_provider_usage", None)) + + +def _budget_request_for( + config: Config, operation: ActionKind, subject_id: str +) -> BudgetRequest: + """Fix the conservative charge for each supported mutating operation.""" + trusted = _trusted_usage_for_subject(config, subject_id) + if operation is ActionKind.EXECUTE_TASK: return BudgetRequest( - intent.subject_id, actions=1, attempts=1, failures=1, parallel=1 + subject_id, + actions=1, + attempts=1, + failures=1, + parallel=1, + provider_usage_trusted=trusted, ) - if intent.operation is ActionKind.REVIEW_TASK: + if operation is ActionKind.REVIEW_TASK: return BudgetRequest( - intent.subject_id, actions=1, attempts=0, failures=1, parallel=1 + subject_id, + actions=1, + attempts=0, + failures=1, + parallel=1, + provider_usage_trusted=trusted, ) raise DyroError("受监督执行当前只支持 execute_task 与 review_task") +def _budget_request(config: Config, intent: ActionIntent) -> BudgetRequest: + return _budget_request_for(config, intent.operation, intent.subject_id) + + +_PREVIEWABLE_ACTIONS = frozenset({ActionKind.EXECUTE_TASK, ActionKind.REVIEW_TASK}) + + +def preview_objective_wave_budgets( + config: Config, + *, + objective: Objective, + actions: Sequence[PlannedAction], + now: datetime, + workspace: BudgetCaps | None = None, +) -> dict[str, object]: + """Read-only budget preview. Never reserves and never starts an Action.""" + automatic = objective.requested_mode is RequestedMode.AUTOMATIC + caps = workspace if workspace is not None else _workspace_budget_caps(config) + all_records = _all_action_records_unlocked(config) + usage = _budget_usage(all_records, objective_id=objective.id) + workspace_usage = _budget_usage(all_records, objective_id=None) + reservations = _reserved_budgets(all_records) + previews: list[dict[str, object]] = [] + for action in actions: + if action.kind not in _PREVIEWABLE_ACTIONS: + continue + decision = decide_budget( + BudgetDecisionInput( + objective_id=objective.id, + requested=objective.budget, + workspace=caps, + activation=None, + usage=usage, + workspace_usage=workspace_usage, + reservations=reservations, + now=now, + request=_budget_request_for(config, action.kind, action.subject_id), + automatic=automatic, + ) + ) + previews.append( + { + "subject_id": action.subject_id, + "operation": action.kind.value, + "allowed": decision.allowed, + "reasons": [reason.value for reason in decision.reasons], + } + ) + return { + "schema_version": 1, + "automatic": automatic, + "provider_cap": caps.max_provider_usage, + "reserved": False, + "actions": previews, + } + + +def render_budget_preview_text(preview: Mapping[str, object]) -> tuple[str, ...]: + """Human tick lines. Preview only; never imply a reservation was taken.""" + lines: list[str] = [] + if preview.get("automatic") and preview.get("provider_cap") is None: + lines.append( + "预算:自动预览没有工作区 provider cap;未信任用量不会硬停" + ) + for item in preview.get("actions", ()): + if not isinstance(item, Mapping) or item.get("allowed"): + continue + reasons = ", ".join(str(reason) for reason in item.get("reasons", ())) + lines.append( + f"预算:{item.get('operation')} {item.get('subject_id')} " + f"若自动执行将被拒绝({reasons})" + ) + return tuple(lines) + + def reserve_supervised_objective_action( config: Config, objective_id: str, @@ -786,6 +896,7 @@ def reserve_supervised_objective_action( intent: ActionIntent, grant: OwnerLeaseGrant, now: datetime, + automatic: bool = False, ) -> tuple[ActionRecord, BudgetDecision]: """Atomically recheck durable budgets and reserve one supervised Action. @@ -806,19 +917,19 @@ def reserve_supervised_objective_action( raise DyroError( "Action intent owner_generation 与当前 Scheduler lease 不匹配" ) - request = _budget_request(intent) + request = _budget_request(config, intent) decision = decide_budget( BudgetDecisionInput( objective_id=objective_id, requested=record.objective.budget, - workspace=BudgetCaps(), + workspace=_workspace_budget_caps(config), activation=None, usage=_budget_usage(all_records, objective_id=objective_id), workspace_usage=_budget_usage(all_records, objective_id=None), reservations=_reserved_budgets(all_records), now=now, request=request, - automatic=False, + automatic=automatic, ) ) if intent.budget_reservation != decision.reservation: diff --git a/src/dyro/continuation/supervision.py b/src/dyro/continuation/supervision.py index 6c889ca..f0d8fa6 100644 --- a/src/dyro/continuation/supervision.py +++ b/src/dyro/continuation/supervision.py @@ -410,6 +410,7 @@ def apply_supervised_wave( Task API compatibility while still enforcing the planner's resource and parallel bounds. Future automatic execution needs a separate process barrier and must not change this explicit-confirmation path. + ``reserve_supervised_objective_action`` stays ``automatic=False`` here. """ # Rebuild the whole semantic wave before writing an owner lease. This # closes both the preview-to-apply race and a direct API caller trying to diff --git a/src/dyro/proof/derive.py b/src/dyro/proof/derive.py index 56cf183..bdd9af9 100644 --- a/src/dyro/proof/derive.py +++ b/src/dyro/proof/derive.py @@ -31,9 +31,18 @@ def list_proofs( line_id: str | None = None, evaluate: bool = True, ) -> tuple[Proof, ...]: - """Rebuild Proofs from disk. `--task` never includes action_receipt.""" + """Rebuild Proofs from disk. + + ``--task`` is task-scoped: no ``action_receipt`` and no + ``trigger_observation``. ``--line`` is line-scoped: task Proofs on that + line plus ``trigger_observation`` from Objectives on that line. + """ if task_id and objective_id: raise ValidationError("proof list 的 --task 与 --objective 互斥") + if task_id and line_id: + raise ValidationError("proof list 的 --task 与 --line 互斥") + if objective_id and line_id: + raise ValidationError("proof list 的 --objective 与 --line 互斥") if objective_id: derived = derive_objective_proofs(config, objective_id) elif task_id: @@ -45,7 +54,9 @@ def list_proofs( proofs: list[Proof] = [] for task in tasks: proofs.extend(derive_task_proofs(config, task)) - if not line_id: + if line_id: + proofs.extend(_derive_line_trigger_proofs(config, line_id)) + else: proofs.extend(derive_trigger_proofs(config)) derived = tuple(_dedupe(proofs)) if not evaluate: @@ -308,6 +319,22 @@ def _derive_signoff(config: Config, task: Task) -> Proof | None: ) +def _derive_line_trigger_proofs(config: Config, line_id: str) -> tuple[Proof, ...]: + """Triggers belong to Objectives; a line filter keeps only that line's.""" + from ..continuation.store import list_objectives + + try: + records = list_objectives(config, recover=False) + except (DyroError, ValidationError, OSError): + return () + proofs: list[Proof] = [] + for record in records: + if record.objective.line != line_id: + continue + proofs.extend(derive_trigger_proofs(config, objective_id=record.objective.id)) + return tuple(_dedupe(proofs)) + + def derive_trigger_proofs( config: Config, *, diff --git a/src/dyro/task_dispatch.py b/src/dyro/task_dispatch.py index b923adc..89f1fd4 100644 --- a/src/dyro/task_dispatch.py +++ b/src/dyro/task_dispatch.py @@ -11,6 +11,7 @@ from experiments.local_agent_dispatch.fileset import SKIP_DIRS from experiments.local_agent_dispatch.task_contract import parse_task_contract +from .capability.cards import card_forbids_execute from .errors import ValidationError from .peer_wave import AUTO_EXECUTOR, assert_write_executor_allowed from .process import Result @@ -110,9 +111,17 @@ def run_task_bound_dispatch( prompt: str, timeout_seconds: float, dry_run: bool = False, + capabilities: Mapping[str, object] | None = None, ) -> Result: if executor == AUTO_EXECUTOR: raise ValidationError("auto executor 必须在派发前绑定到具体 Harness") + if task.risk == "write": + if capabilities is None: + raise ValidationError("write dispatch 必须提供 Capability 平面") + if card_forbids_execute(capabilities.get(executor)): + raise ValidationError( + f"Capability {executor} 未授予 execute,不能作为任务执行器" + ) assert_write_executor_allowed(executor, risk=task.risk) argv = ("dyro", "task-dispatch", executor, task.id) if dry_run: diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index a298d73..075c1d7 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -2043,6 +2043,7 @@ def _execute_task_agent( prompt=prompt, timeout_seconds=float(task.timeout_minutes * 60), dry_run=dry_run, + capabilities=getattr(config, "capabilities", None) or {}, ) elif executor in config.adapters: argv = _adapter_argv( diff --git a/tests/test_bridge_catalog.py b/tests/test_bridge_catalog.py new file mode 100644 index 0000000..15c4cdd --- /dev/null +++ b/tests/test_bridge_catalog.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import unittest + +from dyro.bridge.catalog import ( + EXCLUDED_OPERATION_IDS, + IMPLEMENTED_TESTABLE_IDS, + MANDATORY_OPERATION_IDS, + ExposureCatalog, + build_default_catalog, + compact_catalog, + validate_catalog, +) +from dyro.bridge.models import Availability, CatalogRecord, Risk +from dyro.bridge.schemas import operation_schema +from dyro.errors import ValidationError + + +class BridgeCatalogTests(unittest.TestCase): + def test_fail_closed_hosts_have_no_public_surface(self) -> None: + catalog = build_default_catalog(platform="darwin") + ids = {item.id for item in catalog.operations} + self.assertTrue(MANDATORY_OPERATION_IDS <= ids) + self.assertFalse(ids & EXCLUDED_OPERATION_IDS) + self.assertTrue( + all( + item.availability + in {Availability.DECLARED, Availability.IMPLEMENTED_TESTABLE} + for item in catalog.operations + ) + ) + self.assertFalse( + any(item.availability is Availability.PUBLIC_AVAILABLE for item in catalog.operations) + ) + self.assertEqual( + {item.id for item in catalog.operations if item.availability is Availability.IMPLEMENTED_TESTABLE}, + set(IMPLEMENTED_TESTABLE_IDS), + ) + self.assertFalse(IMPLEMENTED_TESTABLE_IDS & EXCLUDED_OPERATION_IDS) + self.assertTrue(catalog.digest.startswith("sha256:")) + compact = compact_catalog(catalog) + self.assertEqual(compact["schema_version"], 1) + self.assertEqual(len(compact["operations"]), len(catalog.operations)) + with self.assertRaisesRegex(ValidationError, "空的 public surface"): + validate_catalog(catalog, release=True) + windows = build_default_catalog(platform="win32") + self.assertFalse( + any(item.availability is Availability.PUBLIC_AVAILABLE for item in windows.operations) + ) + + def test_linux_release_catalog_promotes_only_mandatory_ids(self) -> None: + catalog = build_default_catalog(platform="linux") + public = { + item.id + for item in catalog.operations + if item.availability is Availability.PUBLIC_AVAILABLE + } + self.assertEqual(public, set(MANDATORY_OPERATION_IDS)) + self.assertFalse(public & EXCLUDED_OPERATION_IDS) + validate_catalog(catalog, release=True) + self.assertNotEqual( + catalog.digest, build_default_catalog(platform="darwin").digest + ) + + def test_catalog_rejects_excluded_and_missing_mandatory(self) -> None: + hello = CatalogRecord( + id="bridge.hello", + risk=Risk.R0, + availability=Availability.DECLARED, + schema_version=1, + must_be_available=True, + core_service="dyro.bridge.transport.hello", + ) + apply = CatalogRecord( + id="objective.apply", + risk=Risk.R2, + availability=Availability.DECLARED, + schema_version=1, + must_be_available=False, + core_service="dyro.bridge.forbidden", + ) + with self.assertRaisesRegex(ValidationError, "禁止"): + validate_catalog(ExposureCatalog(operations=(hello, apply), digest="x")) + with self.assertRaisesRegex(ValidationError, "缺少强制"): + validate_catalog(ExposureCatalog(operations=(hello,), digest="x")) + + def test_schema_fetch_rejects_unknown_and_returns_allowlisted(self) -> None: + schema = operation_schema("bridge.hello", platform="darwin") + self.assertEqual(schema["operation"], "bridge.hello") + self.assertEqual(schema["availability"], "implemented_testable") + self.assertEqual( + operation_schema("bridge.hello", platform="linux")["availability"], + "public_available", + ) + with self.assertRaisesRegex(ValidationError, "未知 operation"): + operation_schema("objective.apply") diff --git a/tests/test_bridge_identity.py b/tests/test_bridge_identity.py new file mode 100644 index 0000000..990ab4e --- /dev/null +++ b/tests/test_bridge_identity.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from dyro.bridge.identity import ( + CONFIG_REVISION_DOMAIN, + PROFILE_MAX_BYTES, + WORKSPACE_IDENTITY_DOMAIN, + config_revision_v1, + workspace_identity_v1, +) +from dyro.errors import ValidationError + + +class BridgeIdentityTests(unittest.TestCase): + def test_identity_changes_when_root_or_name_changes(self) -> None: + with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second: + left = Path(first) + right = Path(second) + same = workspace_identity_v1(canonical_root=left, profile_name="alpha") + again = workspace_identity_v1(canonical_root=left, profile_name="alpha") + renamed = workspace_identity_v1(canonical_root=left, profile_name="beta") + moved = workspace_identity_v1(canonical_root=right, profile_name="alpha") + self.assertEqual(same, again) + self.assertTrue(same.startswith("workspace:")) + self.assertNotEqual(same, renamed) + self.assertNotEqual(same, moved) + self.assertNotIn(str(left), same) + + def test_config_revision_uses_exact_bytes_and_domain(self) -> None: + first = config_revision_v1(b'schema_version = 1\n') + commented = config_revision_v1(b'schema_version = 1\n# note\n') + self.assertNotEqual(first, commented) + self.assertEqual(len(first), 64) + self.assertTrue(WORKSPACE_IDENTITY_DOMAIN.startswith(b"dyro.workspace.identity/v1")) + self.assertTrue(CONFIG_REVISION_DOMAIN.startswith(b"dyro.config.raw/v1")) + + def test_oversized_profile_is_rejected(self) -> None: + with self.assertRaisesRegex(ValidationError, "字节上限"): + config_revision_v1(b"x" * (PROFILE_MAX_BYTES + 1)) diff --git a/tests/test_bridge_models.py b/tests/test_bridge_models.py new file mode 100644 index 0000000..752be93 --- /dev/null +++ b/tests/test_bridge_models.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path +import ast +import unittest + +from dyro.bridge.models import Availability, CatalogRecord, ProtocolVersion, Risk + + +ROOT = Path(__file__).resolve().parents[1] + + +class BridgeModelTests(unittest.TestCase): + def test_protocol_and_catalog_record_are_frozen(self) -> None: + version = ProtocolVersion(1, 0) + record = CatalogRecord( + id="bridge.hello", + risk=Risk.R0, + availability=Availability.DECLARED, + schema_version=1, + must_be_available=True, + core_service="dyro.bridge.transport.hello", + ) + self.assertEqual(version.major, 1) + self.assertEqual(record.risk, Risk.R0) + with self.assertRaises(TypeError): + CatalogRecord( + id="bridge.hello", + risk=Risk.R0, + availability=Availability.DECLARED, + schema_version=1, + must_be_available=True, + core_service="dyro.cli.main", + ) + + def test_bridge_modules_do_not_import_cli(self) -> None: + root = ROOT / "src" / "dyro" / "bridge" + for path in sorted(root.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + self.assertFalse( + alias.name == "dyro.cli" or alias.name.startswith("dyro.cli."), + path.name, + ) + if isinstance(node, ast.ImportFrom) and node.module: + self.assertFalse( + node.module == "dyro.cli" or node.module.startswith("dyro.cli."), + path.name, + ) + if node.level == 2 and node.module == "cli": + self.fail(f"{path.name} imports sibling cli") diff --git a/tests/test_bridge_observations.py b/tests/test_bridge_observations.py new file mode 100644 index 0000000..390bc47 --- /dev/null +++ b/tests/test_bridge_observations.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import ast +from datetime import datetime, timezone +import json +import os +from pathlib import Path +from unittest.mock import patch + +from dyro.bridge.catalog import IMPLEMENTED_TESTABLE_IDS +from dyro.bridge.observations import ( + BridgeObservationError, + explain_task, + gate_definitions, + list_lines_observation, + list_objectives_observation, + list_tasks_observation, + objective_status_observation, + observe_workspace, + task_graph, +) +from dyro.config import load +from dyro.continuation.store import create_objective, list_objectives +from dyro.tasks import task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + +ROOT = Path(__file__).resolve().parents[1] +OBSERVATIONS = ROOT / "src" / "dyro" / "bridge" / "observations.py" + +_CONTRACT = '''schema_version = 1 +id = "observe" +title = "Observe" +line = "alpha" +targets = ["TASK-A"] + +[continuation] +requested_mode = "observe" +operations = ["execute"] +''' + + +def _imported_names(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.module: + names.add(node.module) + names.update(alias.name for alias in node.names) + return names + + +class BridgeObservationTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.home = self.root / "dyro-home" + self.home.mkdir() + self.env = patch.dict(os.environ, {"DYRO_HOME": str(self.home)}, clear=False) + self.env.start() + self.addCleanup(self.env.stop) + self.config = load(self.root) + create_line(self.config, line_id="alpha", branch="feat/alpha", base="main") + directory = self.config.task_specs_dir / "TASK-A" + directory.mkdir(parents=True) + directory.joinpath("task.toml").write_text( + task_template("TASK-A", "Task A", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + directory.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + create_objective(self.config, _CONTRACT) + self.clock = lambda: datetime(2026, 8, 16, 14, 0, tzinfo=timezone.utc) + + def _blob(self, payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, default=str) + + def test_observe_workspace_redacts_paths_and_git_facts(self) -> None: + payload = observe_workspace( + start=self.root, workspace=None, cwd=self.root, clock=self.clock + ) + self.assertEqual(payload["workspace"]["name"], "test-workspace") + self.assertEqual(payload["integration_inspection"], "not_inspected") + self.assertEqual(payload["proof_inspection"], "not_inspected") + self.assertTrue(payload["tasks"]) + self.assertTrue( + all(task["integration_state"] == "not_inspected" for task in payload["tasks"]) + ) + self.assertIn("observe", {item["id"] for item in payload["objectives"]}) + blob = self._blob(payload) + self.assertNotIn(str(self.root.resolve()), blob) + self.assertNotIn("argv", blob) + self.assertNotIn("/usr/bin/true", blob) + + def test_lists_and_status_keep_ready_unknown(self) -> None: + lines = list_lines_observation(self.config) + tasks = list_tasks_observation(self.config) + objectives = list_objectives_observation(self.config) + status = objective_status_observation(self.config, "observe") + self.assertEqual(lines["lines"][0]["id"], "alpha") + self.assertEqual(tasks["integration_inspection"], "not_inspected") + self.assertEqual(objectives["objectives"][0]["id"], "observe") + self.assertIsNone(status["ready"]) + self.assertIsNone(status["blocked"]) + self.assertEqual(status["integration_inspection"], "not_inspected") + self.assertNotIn(str(self.root.resolve()), self._blob((lines, tasks, objectives, status))) + + def test_objective_list_disables_recovery(self) -> None: + with patch( + "dyro.bridge.observations.list_objectives", wraps=list_objectives + ) as mocked: + list_objectives_observation(self.config) + mocked.assert_called_once() + self.assertFalse(mocked.call_args.kwargs["recover"]) + + def test_gate_definitions_omit_argv_and_cannot_import_run_gates(self) -> None: + payload = gate_definitions(self.config, "TASK-A") + self.assertEqual(payload["task_id"], "TASK-A") + self.assertEqual(payload["gates"], [{"name": "diff-check", "timeout_seconds": 120}]) + source = OBSERVATIONS.read_text(encoding="utf-8") + self.assertNotIn("run_gates", source) + imported = _imported_names(OBSERVATIONS) + self.assertNotIn("subprocess", imported) + self.assertNotIn("run_gates", imported) + with self.assertRaises(BridgeObservationError) as ctx: + gate_definitions(self.config, "missing") + self.assertEqual(ctx.exception.code, "TASK_NOT_FOUND") + + def test_authoritative_git_observations_stay_unavailable(self) -> None: + for operation in (explain_task, task_graph): + with self.assertRaises(BridgeObservationError) as ctx: + operation(self.config, "TASK-A") + self.assertEqual(ctx.exception.code, "OPERATION_UNAVAILABLE") + self.assertNotIn("task.explain", IMPLEMENTED_TESTABLE_IDS) + self.assertNotIn("task.graph", IMPLEMENTED_TESTABLE_IDS) diff --git a/tests/test_bridge_plans.py b/tests/test_bridge_plans.py new file mode 100644 index 0000000..939eed3 --- /dev/null +++ b/tests/test_bridge_plans.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import ast +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +from dyro.bridge.constants import PLANNER_REVISIONS +from dyro.bridge.observations import BridgeObservationError +from dyro.bridge.plans import ( + objective_attention, + objective_explain, + objective_graph, + objective_plan, + objective_tick, +) +from dyro.canonical import canonical_json_bytes +from dyro.config import load +from dyro.continuation.store import create_objective, get_objective +from dyro.tasks import task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + +ROOT = Path(__file__).resolve().parents[1] +PLANS = ROOT / "src" / "dyro" / "bridge" / "plans.py" + +_CONTRACT = '''schema_version = 1 +id = "release" +title = "Release" +line = "alpha" +targets = ["TASK-A"] + +[continuation] +requested_mode = "supervised" +operations = ["execute", "review"] + +[budget] +max_actions = 20 +max_attempts_per_task = 2 +max_failures = 3 +max_no_progress_cycles = 2 +max_parallel = 3 +''' + + +def _imported_names(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.module: + names.add(node.module) + names.update(alias.name for alias in node.names) + return names + + +class BridgePlanTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.config = load(self.root) + create_line(self.config, line_id="alpha", branch="feat/alpha", base="main") + directory = self.config.task_specs_dir / "TASK-A" + directory.mkdir(parents=True) + directory.joinpath("task.toml").write_text( + task_template("TASK-A", "Task A", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + directory.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + create_objective(self.config, _CONTRACT) + self.profile_bytes = (self.root / "dyro.toml").read_bytes() + self.clock = lambda: datetime(2026, 8, 16, 15, 0, tzinfo=timezone.utc) + + def _blob(self, payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, default=str) + + def _assert_envelope(self, payload: dict[str, object], operation: str) -> None: + self.assertIs(payload["executable"], False) + self.assertEqual(payload["authorization"], "none") + self.assertEqual(payload["operation"], operation) + self.assertEqual(payload["planner_revision"], PLANNER_REVISIONS[operation]) + self.assertEqual(payload["effective_risk"], "PLAN") + self.assertEqual(payload["read_set"]["integration_inspection"], "not_inspected") + clone = {key: value for key, value in payload.items() if key != "plan_sha256"} + digest = hashlib.sha256(canonical_json_bytes(clone)).hexdigest() + self.assertEqual(payload["plan_sha256"], f"sha256:{digest}") + self.assertNotIn(str(self.root.resolve()), self._blob(payload)) + + def test_plan_digest_is_stable_and_excludes_itself(self) -> None: + first = objective_plan( + self.config, "release", profile_bytes=self.profile_bytes, clock=self.clock + ) + second = objective_plan( + self.config, "release", profile_bytes=self.profile_bytes, clock=self.clock + ) + self._assert_envelope(first, "objective.plan") + self.assertEqual(first["plan_sha256"], second["plan_sha256"]) + mutated = dict(first) + mutated["planner_revision"] = "objective-plan/2" + mutated.pop("plan_sha256") + changed = hashlib.sha256(canonical_json_bytes(mutated)).hexdigest() + self.assertNotEqual(first["plan_sha256"], f"sha256:{changed}") + + def test_all_plan_operations_are_non_executable(self) -> None: + builders = { + "objective.plan": objective_plan, + "objective.explain": objective_explain, + "objective.graph": objective_graph, + "objective.tick": objective_tick, + "objective.attention": objective_attention, + } + for operation, builder in builders.items(): + payload = builder( + self.config, "release", profile_bytes=self.profile_bytes, clock=self.clock + ) + self._assert_envelope(payload, operation) + tick = objective_tick( + self.config, "release", profile_bytes=self.profile_bytes, clock=self.clock + ) + self.assertEqual(tick["projection"]["max_parallel"], 3) + + def test_missing_objective_is_not_found(self) -> None: + with self.assertRaises(BridgeObservationError) as ctx: + objective_plan( + self.config, "missing", profile_bytes=self.profile_bytes, clock=self.clock + ) + self.assertEqual(ctx.exception.code, "OBJECTIVE_NOT_FOUND") + + def test_plans_disable_recovery_and_do_not_probe_path(self) -> None: + record = get_objective(self.config, "release", recover=False) + self.assertEqual(record.objective.id, "release") + source = PLANS.read_text(encoding="utf-8") + self.assertNotIn("discover_available_write_providers", source) + self.assertIn("inspect_integration=False", source) + imported = _imported_names(PLANS) + self.assertNotIn("subprocess", imported) + self.assertNotIn("discover_available_write_providers", imported) + + def test_mutation_modules_do_not_import_bridge_plans(self) -> None: + for relative in ( + "src/dyro/cli.py", + "src/dyro/continuation/store.py", + "src/dyro/tasks.py", + ): + imported = _imported_names(ROOT / relative) + self.assertFalse( + any(name == "dyro.bridge" or name.startswith("dyro.bridge") for name in imported), + relative, + ) diff --git a/tests/test_bridge_redaction.py b/tests/test_bridge_redaction.py new file mode 100644 index 0000000..2ba8d3a --- /dev/null +++ b/tests/test_bridge_redaction.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import unittest + +from dyro.bridge.redaction import echo_request_id, looks_like_absolute_path, looks_sensitive + + +class BridgeRedactionTests(unittest.TestCase): + def test_safe_request_id_is_echoed(self) -> None: + echoed, redacted = echo_request_id("client-1") + self.assertEqual(echoed, "client-1") + self.assertFalse(redacted) + + def test_path_and_secret_request_ids_are_redacted(self) -> None: + for value in ("/tmp/secret", "C:\\Windows\\x", "bearer abc.def", "https://u:p@host/x"): + echoed, redacted = echo_request_id(value) + self.assertIsNone(echoed) + self.assertTrue(redacted) + self.assertTrue(looks_like_absolute_path("/Users/example/project")) + self.assertTrue(looks_sensitive("password=hunter2")) + self.assertFalse(looks_sensitive("client-1")) diff --git a/tests/test_bridge_resolution.py b/tests/test_bridge_resolution.py new file mode 100644 index 0000000..aa55db8 --- /dev/null +++ b/tests/test_bridge_resolution.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import os +from pathlib import Path +import tempfile +from unittest.mock import patch + +from dyro.bridge.identity import workspace_identity_v1 +from dyro.bridge.observations import ( + BridgeObservationError, + list_workspaces_observation, + resolve_workspace_observation, +) +from dyro.hub import add_workspace + +from .support import CONFIG, WorkspaceCase + + +class BridgeResolutionTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.home = self.root / "dyro-home" + self.home.mkdir() + self.env = patch.dict(os.environ, {"DYRO_HOME": str(self.home)}, clear=False) + self.env.start() + self.addCleanup(self.env.stop) + + def test_local_resolution_returns_identity_without_paths(self) -> None: + payload = resolve_workspace_observation( + start=self.root, workspace=None, cwd=self.root + ) + expected = workspace_identity_v1( + canonical_root=self.root.resolve(), profile_name="test-workspace" + ) + self.assertEqual(payload["workspace"]["id"], expected) + self.assertEqual(payload["workspace"]["name"], "test-workspace") + self.assertEqual(payload["resolution_source"], "local") + self.assertNotIn(str(self.root.resolve()), str(payload)) + + def test_malformed_local_profile_never_falls_back_to_registry_default(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bridge-fallback-") as tmp: + fallback = Path(tmp) / "fallback" + fallback.mkdir() + (fallback / "dyro.toml").write_text(CONFIG, encoding="utf-8") + add_workspace(fallback, name="fallback", make_default=True) + (self.root / "dyro.toml").write_text("not valid = [", encoding="utf-8") + with self.assertRaises(BridgeObservationError) as ctx: + resolve_workspace_observation( + start=self.root, workspace=None, cwd=self.root + ) + self.assertEqual(ctx.exception.code, "LOCAL_PROFILE_INVALID") + explicit = resolve_workspace_observation( + start=self.root, workspace="fallback", cwd=self.root + ) + self.assertEqual(explicit["resolution_source"], "explicit") + self.assertEqual(explicit["workspace"]["name"], "test-workspace") + + def test_list_marks_stale_registered_root_without_paths(self) -> None: + add_workspace(self.root, name="sample", make_default=True) + stale = self.root / "stale-ws" + stale.mkdir() + (stale / "dyro.toml").write_text( + CONFIG.replace('name = "test-workspace"', 'name = "stale-ws"'), + encoding="utf-8", + ) + add_workspace(stale, name="stale") + (stale / "dyro.toml").unlink() + payload = list_workspaces_observation() + by_alias = {item["alias"]: item for item in payload["workspaces"]} + self.assertEqual(by_alias["sample"]["status"], "ok") + self.assertEqual(by_alias["stale"]["status"], "stale") + self.assertTrue(payload["partial"]) + self.assertNotIn(str(self.root.resolve()), str(payload)) + self.assertNotIn(str(stale.resolve()), str(payload)) diff --git a/tests/test_bridge_skill.py b/tests/test_bridge_skill.py new file mode 100644 index 0000000..2dd198a --- /dev/null +++ b/tests/test_bridge_skill.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import unittest + +from dyro.bridge.catalog import compact_catalog, build_default_catalog +from dyro.bridge.schemas import operation_schema +from dyro.integrations.manager import DISPATCH_SKILL_NAME, SKILL_NAME + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / "src" / "dyro" / "bridge" / "skill" / "SKILL.md" +MANIFEST = ROOT / "src" / "dyro" / "bridge" / "skill" / "manifest.json" +PYPROJECT = ROOT / "pyproject.toml" + +_POSITIVE = ("inspect", "plan", "workspace", "objective") +_NEGATIVE = ( + "objective apply", + "task run", + "task gates", + "dyro dispatch", + "dyro console", +) + + +class BridgeSkillTests(unittest.TestCase): + def test_skill_is_source_only_and_not_an_integration_asset(self) -> None: + metadata = PYPROJECT.read_text(encoding="utf-8") + self.assertTrue(SKILL.is_file()) + self.assertFalse(json.loads(MANIFEST.read_text(encoding="utf-8"))["installable"]) + self.assertNotIn("dyro-agent-bridge", metadata) + self.assertNotIn("bridge/skill", metadata) + self.assertEqual(SKILL_NAME, "dyro-control-plane") + self.assertEqual(DISPATCH_SKILL_NAME, "dyro-dispatch") + + def test_skill_triggers_and_budget(self) -> None: + text = SKILL.read_text(encoding="utf-8") + lowered = text.lower() + for needle in _POSITIVE: + self.assertIn(needle, lowered) + for needle in _NEGATIVE: + self.assertIn(needle, lowered) + self.assertIn("bridge.capabilities.compact", text) + self.assertIn("bridge.operation.schema", text) + self.assertIn("python -m dyro.bridge", text) + self.assertNotIn("run dyro-bridge", lowered) + compact = json.dumps(compact_catalog(build_default_catalog(platform="linux"))) + schema = json.dumps(operation_schema("workspace.resolve", platform="linux")) + total = len(text.encode("utf-8")) + len(compact.encode("utf-8")) + len(schema.encode("utf-8")) + self.assertLessEqual(len(text.encode("utf-8")), 8192) + self.assertLessEqual(total, 32768) + + def test_module_entry_is_public_and_host_gated(self) -> None: + request = json.dumps( + { + "protocol": {"major": 1, "minor": 0}, + "client": {"name": "bridge-skill-test", "version": "0.0.1"}, + "operation": "bridge.hello", + "input": {}, + }, + separators=(",", ":"), + ).encode("utf-8") + completed = subprocess.run( + (sys.executable, "-m", "dyro.bridge"), + input=request, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertTrue(completed.stdout.endswith(b"\n")) + self.assertEqual(completed.stderr, b"") + payload = json.loads(completed.stdout.decode("utf-8")) + if sys.platform.startswith("linux"): + self.assertEqual(completed.returncode, 0) + self.assertTrue(payload["ok"]) + else: + self.assertEqual(completed.returncode, 4) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["error"]["code"], "OPERATION_UNAVAILABLE") diff --git a/tests/test_bridge_transport.py b/tests/test_bridge_transport.py new file mode 100644 index 0000000..d637590 --- /dev/null +++ b/tests/test_bridge_transport.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from io import BytesIO +import json +from unittest.mock import patch + +from dyro.bridge.catalog import EXCLUDED_OPERATION_IDS, build_default_catalog +from dyro.bridge.models import Availability +from dyro.bridge.parse import MAX_NODES, MAX_REQUEST_BYTES, load_bounded_json +from dyro.bridge.transport import handle_request, serve_once +from dyro.config import load +from dyro.continuation.store import create_objective +from dyro.tasks import task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + + +def _request( + operation: str, + payload: dict[str, object] | None = None, + *, + major: int = 1, + minor: int = 0, + request_id: str | None = "client-1", + extra: dict[str, object] | None = None, +) -> bytes: + body: dict[str, object] = { + "protocol": {"major": major, "minor": minor}, + "client": {"name": "test", "version": "0.0.1"}, + "operation": operation, + "input": {} if payload is None else payload, + } + if request_id is not None: + body["request_id"] = request_id + if extra: + body.update(extra) + return json.dumps(body, separators=(",", ":")).encode("utf-8") + + +class _BrokenStdout(BytesIO): + def write(self, data: bytes) -> int: # type: ignore[override] + raise BrokenPipeError() + + +class BridgeTransportTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.cwd = self.root + + def _handle(self, raw: bytes, *, exposure: str = "testable"): + return handle_request(raw, cwd=self.cwd, exposure=exposure) + + def test_public_exposure_stays_unavailable_off_linux(self) -> None: + code, payload = handle_request( + _request("bridge.hello"), + cwd=self.cwd, + exposure="public", + platform="darwin", + ) + self.assertEqual(code, 4) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["error"]["code"], "OPERATION_UNAVAILABLE") + self.assertEqual(payload["meta"]["requested_protocol"], {"major": 1, "minor": 0}) + + def test_linux_public_exposes_mandatory_but_not_line_list(self) -> None: + hello = handle_request( + _request("bridge.hello"), + cwd=self.cwd, + exposure="public", + platform="linux", + ) + self.assertEqual(hello[0], 0) + self.assertTrue(hello[1]["ok"]) + hidden = handle_request( + _request("line.list", {"start": "."}), + cwd=self.cwd, + exposure="public", + platform="linux", + ) + self.assertEqual(hidden[0], 4) + self.assertEqual(hidden[1]["error"]["code"], "OPERATION_UNAVAILABLE") + + def test_testable_hello_is_one_json_object(self) -> None: + stdout = BytesIO() + stdin = BytesIO(_request("bridge.hello")) + exit_code = serve_once(stdin, stdout, cwd=self.cwd, exposure="testable") + raw = stdout.getvalue() + self.assertEqual(exit_code, 0) + self.assertTrue(raw.endswith(b"\n")) + self.assertEqual(raw.count(b"\n"), 1) + payload = json.loads(raw.decode("utf-8")) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["data"]["bridge_version"], "1.0") + self.assertEqual(payload["data"]["protocol"], {"major": 1, "minor": 0}) + self.assertNotIn("\x1b", raw.decode("utf-8")) + + def test_parse_failures_happen_before_core_access(self) -> None: + with patch( + "dyro.bridge.transport.resolve_workspace_readonly", + side_effect=AssertionError("core"), + ), patch( + "dyro.bridge.transport.resolve_workspace_observation", + side_effect=AssertionError("core"), + ): + oversize = self._handle(b"{" + (b"x" * (MAX_REQUEST_BYTES + 8))) + self.assertEqual(oversize[1]["error"]["code"], "REQUEST_TOO_LARGE") + duplicate = self._handle(b'{"a":1,"a":2}') + self.assertEqual(duplicate[1]["error"]["code"], "INVALID_JSON") + trailing = self._handle(b'{"a":1}{"b":2}') + self.assertEqual(trailing[1]["error"]["code"], "INVALID_JSON") + invalid_utf8 = self._handle(b"\xff\xfe") + self.assertEqual(invalid_utf8[1]["error"]["code"], "INVALID_JSON") + self.assertIsNone(oversize[1]["meta"]["operation"]) + self.assertIsNone(oversize[1]["meta"]["requested_protocol"]) + + def test_deep_and_numerous_structures_fail_closed(self) -> None: + deep = "[" * 65 + "]" * 65 + code, payload = self._handle(deep.encode("utf-8")) + self.assertEqual(payload["error"]["code"], "INVALID_JSON") + self.assertEqual(code, 2) + many = "[" + ",".join("1" for _ in range(MAX_NODES)) + "]" + self.assertEqual(self._handle(many.encode("utf-8"))[1]["error"]["code"], "INVALID_JSON") + long_number = b"1" * 129 + self.assertEqual(self._handle(long_number)[1]["error"]["code"], "INVALID_JSON") + surrogate = b'{"x":"\\uD800"}' + self.assertEqual(self._handle(surrogate)[1]["error"]["code"], "INVALID_JSON") + + def test_schema_and_protocol_fail_closed(self) -> None: + unknown = self._handle(_request("objective.apply")) + self.assertEqual(unknown[1]["error"]["code"], "OPERATION_UNKNOWN") + self.assertIn("objective.apply", EXCLUDED_OPERATION_IDS) + extra_field = self._handle(_request("bridge.hello", extra={"apply": True})) + self.assertEqual(extra_field[1]["error"]["code"], "SCHEMA_VALIDATION_FAILED") + mutation_input = self._handle(_request("bridge.hello", {"dry_run": True})) + self.assertEqual(mutation_input[1]["error"]["code"], "SCHEMA_VALIDATION_FAILED") + major = self._handle(_request("bridge.hello", major=2)) + self.assertEqual(major[1]["error"]["code"], "PROTOCOL_MAJOR_UNSUPPORTED") + minor = self._handle(_request("bridge.hello", minor=1)) + self.assertEqual(minor[1]["error"]["code"], "PROTOCOL_MINOR_UNSUPPORTED") + tilde = self._handle(_request("workspace.resolve", {"start": "~/project"})) + self.assertEqual(tilde[1]["error"]["code"], "SCHEMA_VALIDATION_FAILED") + + def test_request_id_redaction_and_broken_pipe(self) -> None: + code, payload = self._handle(_request("bridge.hello", request_id="/tmp/secret")) + self.assertEqual(code, 0) + self.assertIsNone(payload["meta"]["request_id"]) + self.assertEqual(payload["warnings"][0]["code"], "REQUEST_ID_REDACTED") + exit_code = serve_once( + BytesIO(_request("bridge.hello")), + _BrokenStdout(), + cwd=self.cwd, + exposure="testable", + ) + self.assertEqual(exit_code, 5) + + def test_malformed_local_profile_does_not_fall_back(self) -> None: + (self.root / "dyro.toml").write_text("not valid = [", encoding="utf-8") + code, payload = self._handle(_request("workspace.resolve", {"start": "."})) + self.assertEqual(code, 3) + self.assertEqual(payload["error"]["code"], "LOCAL_PROFILE_INVALID") + self.assertNotIn(str(self.root.resolve()), json.dumps(payload)) + + def test_testable_plan_stays_non_executable(self) -> None: + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + directory = config.task_specs_dir / "TASK-A" + directory.mkdir(parents=True) + directory.joinpath("task.toml").write_text( + task_template("TASK-A", "Task A", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + directory.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + create_objective( + config, + '''schema_version = 1 +id = "release" +title = "Release" +line = "alpha" +targets = ["TASK-A"] + +[continuation] +requested_mode = "supervised" +operations = ["execute"] +''', + ) + code, payload = self._handle( + _request("objective.plan", {"start": ".", "objective_id": "release"}) + ) + self.assertEqual(code, 0) + self.assertIs(payload["data"]["executable"], False) + self.assertEqual(payload["data"]["authorization"], "none") + self.assertEqual(payload["meta"]["planner_revision"], "objective-plan/1") + self.assertNotIn(str(self.root.resolve()), json.dumps(payload)) + + def test_catalog_public_surface_is_linux_only(self) -> None: + darwin = build_default_catalog(platform="darwin") + linux = build_default_catalog(platform="linux") + self.assertFalse( + any(item.availability is Availability.PUBLIC_AVAILABLE for item in darwin.operations) + ) + self.assertEqual( + { + item.id + for item in linux.operations + if item.availability is Availability.PUBLIC_AVAILABLE + }, + { + "bridge.hello", + "bridge.capabilities.compact", + "bridge.operation.schema", + "workspace.resolve", + "workspace.list", + "workspace.observe", + "objective.plan", + }, + ) + + def test_parser_accepts_empty_object(self) -> None: + self.assertEqual(load_bounded_json(b"{}"), {}) diff --git a/tests/test_bridge_zero_effects.py b/tests/test_bridge_zero_effects.py new file mode 100644 index 0000000..e250845 --- /dev/null +++ b/tests/test_bridge_zero_effects.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from contextlib import ExitStack +from io import BytesIO +import json +from unittest.mock import patch + +from dyro.bridge.transport import handle_request, serve_once + +from .support import WorkspaceCase + + +class _Effect(AssertionError): + pass + + +def _request(operation: str, payload: dict[str, object] | None = None) -> bytes: + return json.dumps( + { + "protocol": {"major": 1, "minor": 0}, + "client": {"name": "zero-effect", "version": "0.0.1"}, + "operation": operation, + "input": {} if payload is None else payload, + }, + separators=(",", ":"), + ).encode("utf-8") + + +class BridgeZeroEffectTests(WorkspaceCase): + def _traps(self): + return ( + patch("subprocess.Popen", side_effect=_Effect("popen")), + patch("subprocess.run", side_effect=_Effect("run")), + patch("socket.socket", side_effect=_Effect("socket")), + patch("os.replace", side_effect=_Effect("replace")), + patch("os.rename", side_effect=_Effect("rename")), + patch("os.remove", side_effect=_Effect("remove")), + ) + + def test_linux_public_hello_does_not_write_or_spawn(self) -> None: + with ExitStack() as stack: + for trap in self._traps(): + stack.enter_context(trap) + code, payload = handle_request( + _request("bridge.hello"), + cwd=self.root, + exposure="public", + platform="linux", + ) + self.assertEqual(code, 0) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["data"]["bridge_version"], "1.0") + + def test_linux_public_capabilities_do_not_claim_line_list(self) -> None: + code, payload = handle_request( + _request("bridge.capabilities.compact"), + cwd=self.root, + exposure="public", + platform="linux", + ) + self.assertEqual(code, 0) + available = { + item["id"]: item["availability"] for item in payload["data"]["operations"] + } + self.assertEqual(available["bridge.hello"], "public_available") + self.assertEqual(available["line.list"], "implemented_testable") + self.assertNotIn("objective.apply", available) + + def test_serve_once_writes_only_stdout(self) -> None: + stdout = BytesIO() + before = {path.relative_to(self.root) for path in self.root.rglob("*")} + exit_code = serve_once( + BytesIO(_request("bridge.hello")), + stdout, + cwd=self.root, + exposure="public", + platform="linux", + ) + after = {path.relative_to(self.root) for path in self.root.rglob("*")} + self.assertEqual(exit_code, 0) + self.assertEqual(before, after) + self.assertTrue(stdout.getvalue().endswith(b"\n")) diff --git a/tests/test_capability.py b/tests/test_capability.py index 47e23cf..5c9add2 100644 --- a/tests/test_capability.py +++ b/tests/test_capability.py @@ -31,6 +31,7 @@ def test_adapters_upgrade_to_cards_with_fail_closed_defaults(self) -> None: self.assertEqual(card.attested_isolation.value, "cwd") self.assertEqual(card.cannot_prove, ("done", "merge")) self.assertIn("execute", card.intents) + self.assertFalse(card.trusted_usage) def test_capabilities_table_parses_and_synthesizes_adapter(self) -> None: path = self.root / "dyro.toml" @@ -55,6 +56,7 @@ def test_capabilities_table_parses_and_synthesizes_adapter(self) -> None: self.assertIn("done", card.cannot_prove) self.assertIn("merge", card.cannot_prove) self.assertEqual(card.can_prove, ("review_verdict",)) + self.assertFalse(card.trusted_usage) def test_adapter_and_capability_id_conflict_is_fail_closed(self) -> None: path = self.root / "dyro.toml" @@ -261,6 +263,94 @@ def test_dispatch_ready_without_card_is_explicit_second_door(self) -> None: ) dispatch.assert_called_once() + def test_bound_dispatch_refuses_observe_only_card_without_outer_gate(self) -> None: + from types import SimpleNamespace + + from dyro.task_dispatch import run_task_bound_dispatch + + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-BOUND" + task_path.mkdir(parents=True) + spec = task_template("TASK-BOUND", "bound card", "alpha", "api", "services/api") + task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task = load_task(config, "TASK-BOUND") + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + with self.assertRaisesRegex(ValidationError, "write dispatch 必须提供"): + run_task_bound_dispatch( + task, + executor="echo", + workspace=workspace, + prompt="no", + timeout_seconds=1.0, + ) + with self.assertRaisesRegex(ValidationError, "未授予 execute"): + run_task_bound_dispatch( + task, + executor="echo", + workspace=workspace, + prompt="no", + timeout_seconds=1.0, + capabilities={"echo": SimpleNamespace(intents=("observe",))}, + ) + + def test_trusted_usage_reaches_production_budget_lookup(self) -> None: + from types import SimpleNamespace + + from dyro.continuation.models import ActionKind + from dyro.continuation.store import ( + _budget_request, + _budget_usage, + _trusted_usage_for_subject, + ) + + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8") + + """ + +[[capabilities]] +id = "metered" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +trusted_usage = true +""", + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-METER" + task_path.mkdir(parents=True) + spec = task_template("TASK-METER", "metered usage", "alpha", "api", "services/api") + spec = spec.replace('agent = "codex"', 'agent = "metered"') + task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + + self.assertTrue(config.capabilities["metered"].trusted_usage) + self.assertTrue(_trusted_usage_for_subject(config, "TASK-METER")) + self.assertFalse(_trusted_usage_for_subject(config, "TASK-MISSING")) + self.assertTrue(_budget_usage((), objective_id=None).provider_usage_trusted) + request = _budget_request( + config, + SimpleNamespace(operation=ActionKind.EXECUTE_TASK, subject_id="TASK-METER"), + ) + self.assertTrue(request.provider_usage_trusted) + adapter_task = config.task_specs_dir / "TASK-NOOP" + adapter_task.mkdir(parents=True) + adapter_spec = task_template("TASK-NOOP", "adapter usage", "alpha", "api", "services/api") + adapter_spec = adapter_spec.replace('agent = "codex"', 'agent = "noop"') + adapter_task.joinpath("task.toml").write_text(adapter_spec, encoding="utf-8") + adapter_task.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + adapter_request = _budget_request( + config, + SimpleNamespace(operation=ActionKind.REVIEW_TASK, subject_id="TASK-NOOP"), + ) + self.assertFalse(adapter_request.provider_usage_trusted) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index 3e2a66a..4ccfc3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,6 +11,7 @@ def test_loads_workspace_and_safe_template(self) -> None: config = load(self.root) self.assertEqual(config.name, "test-workspace") self.assertEqual(config.recommended_tool, "") + self.assertIsNone(config.max_provider_usage) self.assertEqual(config.repositories["api"].mount, "services/api") self.assertEqual(expand_argv(("echo", "{workspace}"), workspace=Path("/tmp/work")), ("echo", "/tmp/work")) @@ -35,6 +36,27 @@ def test_loads_and_validates_project_recommended_tool(self) -> None: with self.assertRaisesRegex(ValidationError, "workspace.recommended_tool"): load(self.root) + def test_loads_optional_workspace_provider_cap(self) -> None: + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + 'name = "test-workspace"', + 'name = "test-workspace"\nmax_provider_usage = 40', + ), + encoding="utf-8", + ) + self.assertEqual(load(self.root).max_provider_usage, 40) + + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + "max_provider_usage = 40", + "max_provider_usage = 0", + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValidationError, "workspace.max_provider_usage"): + load(self.root) + def test_recommended_tool_can_be_managed_without_manual_toml_editing(self) -> None: config = load(self.root) set_config_value( diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py index 639b3cc..1e40161 100644 --- a/tests/test_console_assets.py +++ b/tests/test_console_assets.py @@ -64,11 +64,22 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: shell = load_asset("index.html") self.assertIn(b'id="primary-command"', shell.body) + self.assertIn(b'id="task-status-counts"', shell.body) self.assertIn(b'aria-live="polite"', shell.body) self.assertIn(b'class="workspace-column-headings"', shell.body) script = load_asset("app.js") self.assertIn(b"AVAILABILITY_LABELS", script.body) + self.assertIn(b"PROOF_KIND_LABELS", script.body) self.assertIn("任务总数".encode(), script.body) + self.assertIn("摘要 Proof".encode(), script.body) + self.assertIn("function describeTask".encode(), script.body) + self.assertIn("function renderTaskStatusCounts".encode(), script.body) + self.assertIn("待签核".encode(), script.body) + self.assertIn("摘要未核验集成".encode(), script.body) + self.assertIn("开发线".encode(), script.body) + self.assertIn("不是 task merge 放行".encode(), script.body) + self.assertIn("PROOF_LIVE_LABELS".encode(), script.body) + self.assertIn("function proofStatusLabel".encode(), script.body) self.assertIn(b"function workspaceCount", script.body) self.assertIn("仓库".encode(), script.body) self.assertIn("状态不完整".encode(), script.body) diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index ebb7321..9d025cc 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -7,6 +7,7 @@ from pathlib import Path import queue import subprocess +import time import unittest from unittest.mock import Mock, patch @@ -47,6 +48,16 @@ def test_exec_worker_returns_overview_and_single_workspace_without_root_disclosu self.assertEqual(overview["data"]["workspaces"][0]["availability"], "available") self.assertEqual(workspace["data"]["workspace"]["availability"], "available") self.assertEqual(inspect["data"]["proof_inspection"], "inspected") + self.assertEqual(overview["data"]["workspaces"][0]["proof_inspection"], "not_inspected") + self.assertEqual(workspace["data"]["workspace"]["proof_inspection"], "not_inspected") + self.assertEqual(set(workspace["data"]), {"workspace", "lines", "tasks", "objectives"}) + self.assertNotIn("proofs", workspace["data"]) + self.assertTrue( + all( + item.get("integration_state") == "not_inspected" + for item in workspace["data"]["tasks"] + ) + ) self.assertNotIn("procedure", repr(inspect)) self.assertNotIn(str(self.root), repr(overview)) self.assertNotIn(str(self.root), repr(workspace)) @@ -145,6 +156,92 @@ def test_worker_timeout_kills_its_process_group_and_returns_a_stable_code(self) killpg.assert_called_once() + def test_windows_inspection_fails_closed_without_starting_a_worker(self) -> None: + service = IsolatedOverviewService(registry_state_home=Path("/tmp")) + with ( + patch("dyro.console.inspection.os.name", "nt"), + patch("dyro.console.inspection.subprocess.Popen") as popen, + self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"), + ): + service.page() + popen.assert_not_called() + with ( + patch("dyro.console.inspection.os.name", "nt"), + patch("dyro.console.inspection.subprocess.Popen") as popen, + self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"), + ): + service.inspect_proofs("demo") + popen.assert_not_called() + + def test_inspect_timeout_kills_its_process_group_and_returns_a_stable_code(self) -> None: + process = Mock(spec=subprocess.Popen) + process.pid = 12345 + process.communicate.side_effect = [ + subprocess.TimeoutExpired(["worker"], 0.1), + (b"", b""), + ] + service = IsolatedOverviewService(registry_state_home=Path("/tmp")) + + with ( + patch("dyro.console.inspection.subprocess.Popen", return_value=process), + patch("dyro.console.inspection.os.killpg") as killpg, + self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_TIMEOUT"), + ): + service.inspect_proofs("demo") + + killpg.assert_called_once() + + def test_inspect_timeout_reaps_hung_descendants(self) -> None: + if os.name == "nt" or not hasattr(os, "killpg"): + self.skipTest("inspect process-group kill is POSIX-only") + marker = self.root / "hung-inspect-descendant.pid" + wrapper = self.root / "hang-inspect-python" + wrapper.write_text( + "\n".join( + ( + "#!/usr/bin/env python3", + "import subprocess", + "import sys", + "import time", + "from pathlib import Path", + f"marker = Path({str(marker)!r})", + "child = subprocess.Popen(", + " [sys.executable, '-c', 'import time; time.sleep(60)']", + ")", + "marker.write_text(str(child.pid), encoding='utf-8')", + "time.sleep(60)", + "", + ) + ), + encoding="utf-8", + ) + wrapper.chmod(0o755) + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=0.6, + cursor_secret=b"q" * 32, + python_executable=str(wrapper), + ) + + with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_TIMEOUT"): + service.inspect_proofs("demo") + + deadline = time.monotonic() + 2.0 + pid = 0 + while time.monotonic() < deadline: + if marker.exists(): + pid = int(marker.read_text(encoding="utf-8")) + break + time.sleep(0.05) + self.assertGreater(pid, 1, "hung inspect descendant did not start") + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except OSError: + return + time.sleep(0.05) + self.fail("hung inspect descendant survived process-group kill") + def test_invalid_worker_output_fails_closed_without_echoing_it(self) -> None: service = IsolatedOverviewService(registry_state_home=Path("/tmp")) with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE") as raised: @@ -203,6 +300,99 @@ def test_parent_rejects_inspect_payload_with_procedure_or_paths(self) -> None: with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): service._parse_worker_output(raw, expected_operation="inspect_proofs") + def test_parent_rejects_an_inspected_summary_card(self) -> None: + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + valid = service.workspace("demo") + payload = deepcopy(valid) + payload["data"]["workspace"]["proof_inspection"] = "inspected" + payload["snapshot_sha256"] = hashlib.sha256( + canonical_json_bytes( + { + "schema_version": 1, + "freshness": payload["freshness"], + "data": payload["data"], + } + ) + ).hexdigest() + raw = json.dumps({"ok": True, "payload": payload}).encode("utf-8") + with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): + service._parse_worker_output(raw, expected_operation="workspace") + + def test_parent_rejects_workspace_inventory_that_leaks_inspect(self) -> None: + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + valid = service.workspace("demo") + + integrated = deepcopy(valid) + integrated["data"]["tasks"] = [ + { + "id": "TASK-A", + "title": "Safe task", + "line": "alpha", + "status": "done", + "risk": "write", + "depends_on": [], + "blocked_on": [], + "conflict_group": "", + "executor": "codex", + "reviewer": "codex", + "integration_state": "integrated", + "external_claim_active": False, + } + ] + decayed = deepcopy(valid) + decayed["data"]["objectives"] = [ + { + "id": "release", + "title": "Safe release", + "line": "alpha", + "revision": 1, + "operator_state": "active", + "derived_result": "incomplete", + "requested_mode": "supervised", + "operations": ["execute"], + "scope_count": 1, + "budget": {"max_actions": 2}, + "selected_actions": [], + "blocked_actions": [], + "attention": [ + { + "kind": "needs_user", + "subject_id": "TASK-A", + "reason": "PROOF_DECAYED", + } + ], + "contract_sha256": "c" * 64, + "scope_sha256": "d" * 64, + "event_sha256": "e" * 64, + } + ] + proofs = deepcopy(valid) + proofs["data"]["proofs"] = [] + missing = deepcopy(valid) + del missing["data"]["lines"] + + for payload in (integrated, decayed, proofs, missing): + payload["snapshot_sha256"] = hashlib.sha256( + canonical_json_bytes( + { + "schema_version": 1, + "freshness": payload["freshness"], + "data": payload["data"], + } + ) + ).hexdigest() + raw = json.dumps({"ok": True, "payload": payload}).encode("utf-8") + with self.subTest(keys=sorted(payload["data"])): + with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): + service._parse_worker_output(raw, expected_operation="workspace") if __name__ == "__main__": unittest.main() diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index b821ab0..8589402 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -154,6 +154,7 @@ def test_paginates_stably_prioritizes_attention_and_never_exposes_roots(self) -> self.assertEqual(first["data"]["highest_priority"]["kind"], "repair_required") self.assertEqual(first["data"]["attention_counts"]["needs_user"], 1) self.assertEqual(first["data"]["attention_counts"]["repair_required"], 1) + self.assertEqual(first["data"]["task_status_counts"], {"backlog": 2}) self.assertIn("WORKSPACE_UNAVAILABLE", first["freshness"]["warnings"][1]["code"]) self.assertNotIn("/private", repr(first)) self.assertNotIn("dyro.toml", repr(first)) @@ -225,14 +226,52 @@ def test_warning_only_change_invalidates_the_page_etag(self) -> None: def test_single_workspace_reuses_the_same_summary_and_rejects_unsafe_aliases(self) -> None: payload = self.service.workspace("alpha") + page = self.service.page(limit=3) self.assertEqual(payload["data"]["workspace"]["alias"], "alpha") + self.assertEqual(payload["data"]["workspace"]["proof_inspection"], "not_inspected") + self.assertEqual(payload["data"]["lines"][0]["id"], "alpha") + self.assertEqual(payload["data"]["tasks"][0]["id"], "TASK-A") + self.assertEqual(payload["data"]["tasks"][0]["integration_state"], "not_inspected") + self.assertEqual(payload["data"]["objectives"][0]["id"], "release") + self.assertNotIn("proofs", payload["data"]) + self.assertNotIn("lines", page["data"]) + self.assertNotIn("tasks", page["data"]) + self.assertNotIn("objectives", page["data"]) self.assertNotIn("/private", repr(payload)) with self.assertRaisesRegex(ConsoleOverviewError, "WORKSPACE_ALIAS_INVALID"): self.service.workspace("%2fprivate") with self.assertRaisesRegex(ConsoleOverviewError, "WORKSPACE_NOT_FOUND"): self.service.workspace("missing") + def test_overview_task_status_counts_ignore_unavailable_workspaces(self) -> None: + self.registry = WorkspaceRegistry( + default="broken", + workspaces=(WorkspaceRecord("broken", self.broken_root),), + ) + service = ConsoleOverviewService( + registry_loader=lambda: self.registry, + config_loader=self.service._config_loader, + snapshot_loader=self.service._snapshot_loader, + clock=self.service._clock, + cursor_secret=b"k" * 32, + ) + + payload = service.page() + + self.assertEqual(payload["data"]["workspaces"][0]["availability"], "unavailable") + self.assertEqual(payload["data"]["task_status_counts"], {}) + + def test_unavailable_workspace_keeps_empty_inventory_keys(self) -> None: + payload = self.service.workspace("broken") + + self.assertEqual(payload["data"]["workspace"]["availability"], "unavailable") + self.assertEqual(payload["data"]["workspace"]["proof_inspection"], "not_inspected") + self.assertEqual(payload["data"]["lines"], []) + self.assertEqual(payload["data"]["tasks"], []) + self.assertEqual(payload["data"]["objectives"], []) + self.assertNotIn("proofs", payload["data"]) + def test_inspect_proofs_does_not_use_summary_loader_and_can_show_decay(self) -> None: inspected = _snapshot( name="Alpha Project", @@ -261,6 +300,19 @@ def summary_loader(config: object) -> WorkspaceReadSnapshot: clock=lambda: datetime(2026, 8, 4, 12, 5, tzinfo=timezone.utc), cursor_secret=b"k" * 32, ) + leaked = ConsoleOverviewService( + registry_loader=lambda: self.registry, + config_loader=self.service._config_loader, + snapshot_loader=lambda config: inspected, + inspect_loader=lambda config: inspected, + clock=lambda: datetime(2026, 8, 4, 12, 5, tzinfo=timezone.utc), + cursor_secret=b"k" * 32, + ) + summary = leaked.workspace("alpha") + self.assertEqual(summary["data"]["workspace"]["proof_inspection"], "not_inspected") + self.assertEqual(summary["data"]["tasks"][0]["integration_state"], "not_inspected") + self.assertNotIn("proofs", summary["data"]) + self.assertNotIn("PROOF_DECAYED", repr(summary["data"]["objectives"])) payload = service.inspect_proofs("alpha") self.assertEqual(payload["data"]["proof_inspection"], "inspected") self.assertEqual(payload["data"]["proofs"][0]["status"], "decayed") diff --git a/tests/test_console_server.py b/tests/test_console_server.py index 6852dbd..cd81f80 100644 --- a/tests/test_console_server.py +++ b/tests/test_console_server.py @@ -112,7 +112,8 @@ def test_api_requires_exact_host_authorization_and_origin(self) -> None: self.assertEqual(headers["Content-Type"], "application/json; charset=utf-8") payload = json.loads(body) self.assertEqual(payload["schema_version"], 1) - self.assertEqual(payload["data"]["capabilities"], ["overview"]) + self.assertEqual(payload["data"]["surfaces"], ["overview", "proofs"]) + self.assertEqual(payload["data"]["capabilities"], ["overview", "proofs"]) self.assertEqual(payload["data"]["initial_workspace"], "") self.assertIn("session_expires_at", payload["data"]) diff --git a/tests/test_continuation_budget_preview.py b/tests/test_continuation_budget_preview.py new file mode 100644 index 0000000..7164fa0 --- /dev/null +++ b/tests/test_continuation_budget_preview.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from io import StringIO +import inspect +import json +from pathlib import Path + +from dyro.cli import main +from dyro.config import load +from dyro.continuation.models import ActionKind, PlannedAction, ReasonCode +from dyro.continuation.store import ( + create_objective, + list_objective_actions, + preview_objective_wave_budgets, + render_budget_preview_text, + reserve_supervised_objective_action, +) +from dyro.tasks import task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + + +def _contract(*, mode: str) -> str: + return f'''schema_version = 1 +id = "release" +title = "Release" +line = "alpha" +targets = ["TASK-A"] + +[continuation] +requested_mode = "{mode}" +operations = ["execute", "review"] + +[budget] +max_actions = 20 +max_attempts_per_task = 2 +max_failures = 3 +max_no_progress_cycles = 2 +max_parallel = 1 +''' + + +class AutomaticBudgetPreviewTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.now = datetime(2026, 8, 16, 12, 0, tzinfo=timezone.utc) + + def _write_task(self, config, task_id: str = "TASK-A") -> Path: + directory = config.task_specs_dir / task_id + directory.mkdir(parents=True) + directory.joinpath("task.toml").write_text( + task_template(task_id, "Task A", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + directory.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + return directory + + def _prepare(self, *, mode: str, provider_cap: int | None = None): + if provider_cap is not None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8").replace( + 'name = "test-workspace"', + f'name = "test-workspace"\nmax_provider_usage = {provider_cap}', + ), + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + self._write_task(config) + record = create_objective(config, _contract(mode=mode)) + return config, record + + def _execute_action(self) -> PlannedAction: + return PlannedAction( + kind=ActionKind.EXECUTE_TASK, + subject_id="TASK-A", + reason=ReasonCode.TASK_READY, + ) + + def _run_cli(self, *argv: str) -> tuple[int, str, str]: + stdout = StringIO() + stderr = StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + main(["--root", str(self.root), *argv]) + code = 0 + except SystemExit as exc: + code = 0 if exc.code is None else int(exc.code) + return code, stdout.getvalue(), stderr.getvalue() + + def test_automatic_untrusted_usage_hard_stops_only_when_cap_exists(self) -> None: + config, record = self._prepare(mode="automatic", provider_cap=100) + preview = preview_objective_wave_budgets( + config, + objective=record.objective, + actions=(self._execute_action(),), + now=self.now, + ) + self.assertTrue(preview["automatic"]) + self.assertEqual(preview["provider_cap"], 100) + self.assertFalse(preview["reserved"]) + self.assertEqual(preview["actions"][0]["reasons"], ["PROVIDER_USAGE_UNTRUSTED"]) + self.assertFalse(preview["actions"][0]["allowed"]) + self.assertEqual(list_objective_actions(config, "release"), ()) + + def test_supervised_preview_does_not_invent_untrusted_hard_stop(self) -> None: + config, record = self._prepare(mode="supervised", provider_cap=100) + preview = preview_objective_wave_budgets( + config, + objective=record.objective, + actions=(self._execute_action(),), + now=self.now, + ) + self.assertFalse(preview["automatic"]) + self.assertTrue(preview["actions"][0]["allowed"]) + self.assertEqual(preview["actions"][0]["reasons"], []) + + def test_automatic_without_cap_does_not_hard_stop(self) -> None: + config, record = self._prepare(mode="automatic") + preview = preview_objective_wave_budgets( + config, + objective=record.objective, + actions=(self._execute_action(),), + now=self.now, + ) + self.assertTrue(preview["automatic"]) + self.assertIsNone(preview["provider_cap"]) + self.assertTrue(preview["actions"][0]["allowed"]) + self.assertIn("没有工作区 provider cap", "\n".join(render_budget_preview_text(preview))) + + def test_trusted_card_allows_automatic_preview_when_cap_exists(self) -> None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8") + + """ + +[[capabilities]] +id = "metered" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +trusted_usage = true +""", + encoding="utf-8", + ) + config, record = self._prepare(mode="automatic", provider_cap=100) + task_path = config.task_specs_dir / "TASK-A" + spec = task_path.joinpath("task.toml").read_text(encoding="utf-8") + task_path.joinpath("task.toml").write_text( + spec.replace('agent = "noop"', 'agent = "metered"'), + encoding="utf-8", + ) + preview = preview_objective_wave_budgets( + config, + objective=record.objective, + actions=(self._execute_action(),), + now=self.now, + ) + self.assertTrue(preview["actions"][0]["allowed"]) + self.assertEqual(preview["actions"][0]["reasons"], []) + + def test_tick_json_exposes_preview_without_reserving(self) -> None: + self._prepare(mode="automatic", provider_cap=50) + code, stdout, _stderr = self._run_cli( + "objective", "tick", "release", "--format", "json" + ) + self.assertEqual(code, 0) + payload = json.loads(stdout) + preview = payload["budget_preview"] + self.assertTrue(preview["automatic"]) + self.assertEqual(preview["provider_cap"], 50) + self.assertFalse(preview["reserved"]) + self.assertTrue( + any( + "PROVIDER_USAGE_UNTRUSTED" in item.get("reasons", []) + for item in preview["actions"] + ) + ) + self.assertEqual(list_objective_actions(load(self.root), "release"), ()) + + def test_plan_json_exposes_the_same_preview_without_reserving(self) -> None: + self._prepare(mode="automatic", provider_cap=50) + code, stdout, _stderr = self._run_cli( + "objective", "plan", "release", "--format", "json" + ) + self.assertEqual(code, 0) + payload = json.loads(stdout) + preview = payload["budget_preview"] + self.assertTrue(preview["automatic"]) + self.assertEqual(preview["provider_cap"], 50) + self.assertFalse(preview["reserved"]) + self.assertEqual(list_objective_actions(load(self.root), "release"), ()) + + def test_reserve_stays_manual_by_default(self) -> None: + self.assertFalse( + inspect.signature(reserve_supervised_objective_action) + .parameters["automatic"] + .default + ) diff --git a/tests/test_continuation_budgets.py b/tests/test_continuation_budgets.py index d76a68f..3d4c131 100644 --- a/tests/test_continuation_budgets.py +++ b/tests/test_continuation_budgets.py @@ -196,6 +196,7 @@ def test_hard_provider_cap_requires_a_trusted_action_reservation(self) -> None: self.assertIn(BudgetReason.PROVIDER_USAGE_UNTRUSTED, unknown.reasons) self.assertTrue(explicit_zero.allowed) + self.assertFalse(BudgetUsage().provider_usage_trusted) with self.assertRaisesRegex(TypeError, "automatic 必须是 bool"): BudgetDecisionInput(request=BudgetRequest("TASK-A"), automatic=0, **base) # type: ignore[arg-type] diff --git a/tests/test_continuation_supervision.py b/tests/test_continuation_supervision.py index b8374d8..03d735f 100644 --- a/tests/test_continuation_supervision.py +++ b/tests/test_continuation_supervision.py @@ -188,6 +188,26 @@ def test_started_action_consumes_budget_before_any_replay(self) -> None: self.assertEqual(first[0].status, ActionStatus.SUCCEEDED) self.assertEqual(runner.call_count, 1) + def test_supervised_apply_ignores_untrusted_usage_even_with_provider_cap(self) -> None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8").replace( + 'name = "test-workspace"', + 'name = "test-workspace"\nmax_provider_usage = 100', + ), + encoding="utf-8", + ) + self.config = load(self.root) + self.task_directory.joinpath("receipt.md").write_text( + "result: DONE\n", encoding="utf-8" + ) + wave = self._wave() + outcomes = apply_supervised_wave(self.config, wave, clock=lambda: self.now) + self.assertEqual( + [(item.status, item.result) for item in outcomes], + [(ActionStatus.SUCCEEDED, "review")], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_peer_wave.py b/tests/test_peer_wave.py index 786305e..f425f60 100644 --- a/tests/test_peer_wave.py +++ b/tests/test_peer_wave.py @@ -81,6 +81,7 @@ def test_cursor_cannot_run_bound_write_dispatch(self) -> None: workspace=workspace, prompt="do not write", timeout_seconds=1.0, + capabilities={}, ) def test_echo_bound_dispatch_writes_in_given_worktree_not_detached_tree( @@ -96,6 +97,7 @@ def test_echo_bound_dispatch_writes_in_given_worktree_not_detached_tree( workspace=workspace, prompt="result: DONE", timeout_seconds=1.0, + capabilities={}, ) self.assertEqual(result.code, 0) self.assertIn("echo-adapter", result.stdout) diff --git a/tests/test_proof_decay.py b/tests/test_proof_decay.py index 483d1fd..6886acb 100644 --- a/tests/test_proof_decay.py +++ b/tests/test_proof_decay.py @@ -107,7 +107,7 @@ def test_line_prepare_merge_is_not_proof_decayed(self) -> None: self.assertNotEqual(decision.reason, LINE_PREPARE_NOT_DECAY) self.assertNotEqual(decision.status, ProofStatus.DECAYED) - def test_git_revert_is_not_ancestor_break(self) -> None: + def test_integration_heads_stay_live_when_ancestor_check_passes(self) -> None: proof = _proof(ProofKind.INTEGRATION_HEADS) decision = decay(proof, None, clock=CLOCK, integration_ok=True) self.assertEqual(decision.status, ProofStatus.LIVE) @@ -362,8 +362,22 @@ def test_line_dirty_is_prepare_merge_not_proof_decayed(self) -> None: integration = next(proof for proof in proofs if proof.kind is ProofKind.INTEGRATION_HEADS) self.assertEqual(review.status, ProofStatus.LIVE) self.assertEqual(integration.status, ProofStatus.LIVE) - with self.assertRaisesRegex(DyroError, "开发线仓库不干净"): + with self.assertRaisesRegex(DyroError, "开发线仓库不干净") as raised: merge_task(config, task) + self.assertNotIn("PROOF_DECAYED", str(raised.exception)) + + def test_line_wrong_branch_is_prepare_merge_not_proof_decayed(self) -> None: + config, task = self._reviewed_task("TASK-LINE-BRANCH") + line_repo = self.root / "versions/alpha/services/api" + shell("git", "checkout", "-B", "wrong-branch", cwd=line_repo) + proofs = evaluate_proofs(config, derive_task_proofs(config, task)) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + integration = next(proof for proof in proofs if proof.kind is ProofKind.INTEGRATION_HEADS) + self.assertEqual(review.status, ProofStatus.LIVE) + self.assertEqual(integration.status, ProofStatus.LIVE) + with self.assertRaisesRegex(DyroError, "开发线仓库分支错误") as raised: + merge_task(config, task) + self.assertNotIn("PROOF_DECAYED", str(raised.exception)) def _downstream(self, config, task_id: str, dependency: str): path = config.task_specs_dir / task_id diff --git a/tests/test_proof_derive.py b/tests/test_proof_derive.py index 93bcda9..d1d63ce 100644 --- a/tests/test_proof_derive.py +++ b/tests/test_proof_derive.py @@ -6,6 +6,7 @@ from pathlib import Path from dyro.config import load +from dyro.errors import ValidationError from dyro.continuation.actions import ( ActionIntent, ActionReceipt, @@ -285,6 +286,20 @@ def test_trigger_file_derives_stable_identity_and_is_excluded_from_task_filter(s any(proof.kind is ProofKind.TRIGGER_OBSERVATION for proof in list_proofs(config, objective_id="release")) ) self.assertTrue(any(proof.kind is ProofKind.TRIGGER_OBSERVATION for proof in list_proofs(config))) + self.assertTrue( + any( + proof.kind is ProofKind.TRIGGER_OBSERVATION + for proof in list_proofs(config, line_id="alpha") + ) + ) + self.assertFalse( + any( + proof.kind is ProofKind.TRIGGER_OBSERVATION + for proof in list_proofs(config, line_id="other") + ) + ) + with self.assertRaisesRegex(ValidationError, "--task 与 --line"): + list_proofs(config, task_id="TASK-A", line_id="alpha") due = evaluate_proofs( config, first, diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py index b4d95c9..6afaac9 100644 --- a/tests/test_release_gates.py +++ b/tests/test_release_gates.py @@ -4,6 +4,7 @@ from io import StringIO from pathlib import Path import sys +import tempfile import unittest from unittest.mock import patch @@ -47,10 +48,40 @@ def test_untagged_0_7_runs_0_7_gates(self) -> None: def test_later_0_7_x_runs_0_7_gates_without_claiming_1_0(self) -> None: stdout = StringIO() - with patch("verify_release_gates._version", return_value="0.7.1"): + with patch("verify_release_gates._version", return_value="0.7.99"): with redirect_stdout(stdout): - code = main(["--root", str(ROOT), "--release-tag", "v0.7.1"]) + code = main(["--root", str(ROOT), "--release-tag", "v0.7.99"]) self.assertEqual(code, 0) self.assertIn("0.7 gates present", stdout.getvalue()) self.assertNotIn("1.0 gates present", stdout.getvalue()) + self.assertNotIn("1.0 identity gates present", stdout.getvalue()) self.assertNotIn("skip 1.0 gates", stdout.getvalue()) + self.assertNotIn("skip physics gates", stdout.getvalue()) + + def test_commented_python_marker_does_not_count(self) -> None: + from verify_release_gates import _contains_marker + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "probe.py" + path.write_text("# def verify_bundle\nvalue = 1\n", encoding="utf-8") + self.assertFalse(_contains_marker(path, "def verify_bundle")) + path.write_text("def verify_bundle():\n return None\n", encoding="utf-8") + self.assertTrue(_contains_marker(path, "def verify_bundle")) + + def test_0_8_and_0_9_are_refused_as_wrong_feature_numbers(self) -> None: + for version in ("0.8.0", "0.9.1"): + with self.subTest(version=version): + with patch("verify_release_gates._version", return_value=version): + with self.assertRaises(SystemExit) as raised: + main(["--root", str(ROOT), "--release-tag", f"v{version}"]) + self.assertIn("0.7.x", str(raised.exception)) + + def test_1_0_0_keeps_the_stricter_0_7_x_contract(self) -> None: + stdout = StringIO() + with patch("verify_release_gates._version", return_value="1.0.0"): + with redirect_stdout(stdout): + code = main(["--root", str(ROOT), "--release-tag", "v1.0.0"]) + self.assertEqual(code, 0) + self.assertIn("1.0 identity gates present", stdout.getvalue()) + self.assertNotIn("skip 1.0 gates", stdout.getvalue()) + self.assertNotIn("skip physics gates", stdout.getvalue()) diff --git a/tests/test_release_source.py b/tests/test_release_source.py index f2c0777..3ff3d45 100644 --- a/tests/test_release_source.py +++ b/tests/test_release_source.py @@ -107,3 +107,13 @@ def test_ci_no_longer_ships_agent_bridge_zero_effect_gate(self) -> None: self.assertNotIn("Agent Bridge source/wheel/sdist gate", workflow) self.assertIn("dyro-dispatch','SKILL.md", workflow) self.assertIn("dyro-dispatch','agents','openai.yaml", workflow) + self.assertGreaterEqual(workflow.count("verify_bundle_stranger.py"), 2) + + def test_default_wheel_stays_bridge_free(self) -> None: + metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + self.assertNotIn("dyro-bridge", metadata) + self.assertNotIn("dyro-mcp", metadata) + self.assertNotIn('"dyro.bridge"', metadata) + self.assertNotIn("[project.optional-dependencies.mcp]", metadata) + self.assertNotIn("[mcp]", metadata) + self.assertIn('dyro = "dyro.cli:main"', metadata) diff --git a/tools/verify_bundle_stranger.py b/tools/verify_bundle_stranger.py index c3609eb..63a4c6f 100644 --- a/tools/verify_bundle_stranger.py +++ b/tools/verify_bundle_stranger.py @@ -91,6 +91,8 @@ def main(argv: list[str] | None = None) -> int: absent = json.loads(missing.stdout or "{}") if any(item.get("status") == "live" for item in absent.get("proofs", [])): raise SystemExit("missing git objects must be inconclusive, not live") + if any(item.get("status") == "decayed" for item in absent.get("proofs", [])): + raise SystemExit("missing git objects must not report decayed") print(json.dumps({"ok": True, "mode": "integrity", "live": True}, sort_keys=True)) return 0 diff --git a/tools/verify_release_gates.py b/tools/verify_release_gates.py index 9450be8..8c7392f 100644 --- a/tools/verify_release_gates.py +++ b/tools/verify_release_gates.py @@ -4,12 +4,17 @@ as 0.7.x, not 0.8 / 0.9 / 1.0. A 0.7.x tag must pass 0.7 gates and must not be narrated as a 1.0 release. 1.0.0 is an identity freeze, not this series' feature number; if that tag is ever cut, it still keeps the stricter stranger contract. + +Markers must appear in real source, not comments. """ from __future__ import annotations import argparse +import io +import re from pathlib import Path +import tokenize import tomllib @@ -28,6 +33,7 @@ ("P13-bundle-contract-en", Path("README.md"), "verify-bundle"), ("P13-a1-lock", Path("tests/test_proof_a1_boundary.py"), "dyro.proof"), ("P13-stranger", Path("tools/verify_bundle_stranger.py"), "without --git-dir must not be live"), + ("P13-stranger-decayed", Path("tools/verify_bundle_stranger.py"), "must not report decayed"), ("P0-missing-git", Path("src/dyro/proof/bundle.py"), "if not git_dirs:"), ) @@ -46,6 +52,8 @@ ("trigger-derive", Path("src/dyro/proof/derive.py"), "def derive_trigger_proofs"), ) +PHYSICS_GATES = SEVEN_GATES + SEVEN_X_GATES + def _version(root: Path) -> str: metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) @@ -54,16 +62,56 @@ def _version(root: Path) -> str: def _is_physics_train(root: Path) -> bool: bundle = root / "src/dyro/proof/bundle.py" - return bundle.is_file() and "def verify_bundle" in bundle.read_text(encoding="utf-8") + return bundle.is_file() and _contains_marker(bundle, "def verify_bundle") + + +def _offset(text: str, start: tuple[int, int]) -> int: + line, column = start + if line <= 1: + return min(column, len(text)) + index = 0 + for _ in range(line - 1): + newline = text.find("\n", index) + if newline < 0: + return len(text) + index = newline + 1 + return min(index + column, len(text)) + + +def _python_without_comments(text: str) -> str: + chars = list(text) + try: + for token in tokenize.generate_tokens(io.StringIO(text).readline): + if token.type != tokenize.COMMENT: + continue + begin = _offset(text, token.start) + end = _offset(text, token.end) + chars[begin:end] = [" "] * max(0, end - begin) + except tokenize.TokenError: + return "" + return "".join(chars) + + +def _markdown_without_comments(text: str) -> str: + return re.sub(r"", "", text, flags=re.S) + + +def _contains_marker(path: Path, marker: str) -> bool: + text = path.read_text(encoding="utf-8") + if path.suffix == ".py": + text = _python_without_comments(text) + elif path.suffix == ".md": + text = _markdown_without_comments(text) + return marker in text def missing_gates(root: Path, gates: tuple[tuple[str, Path, str], ...] = GATES) -> list[str]: missing: list[str] = [] for name, path, marker in gates: target = root / path - if not target.is_file() or marker not in target.read_text(encoding="utf-8"): + if not target.is_file() or not _contains_marker(target, marker): missing.append(name) - if (root / "src/dyro/proof/bundle.py").read_text(encoding="utf-8").find("def refuse_verify_bundle") >= 0: + if _contains_marker(root / "src/dyro/proof/bundle.py", "def refuse_verify_bundle"): missing.append("P13-refuse-removed") return missing @@ -72,6 +120,14 @@ def _tag_name(tag: str) -> str: return tag[1:] if tag.startswith("v") else tag +def _refuse_wrong_feature_number(version: str) -> str | None: + if version.startswith(("0.8.", "0.9.")): + return f"拒绝 {version}:功能号必须停在 0.7.x" + if version.startswith("1.") and version != "1.0.0": + return f"拒绝 {version}:1.x 只允许以后的身份冻结号 1.0.0" + return None + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", default=".") @@ -86,19 +142,24 @@ def main(argv: list[str] | None = None) -> int: raise SystemExit("拒绝:本树已含 Proof/Card/Compiler,不得作为 0.6.x 发布") if tag and _tag_name(tag) != version: raise SystemExit(f"拒绝:release tag {tag!r} 必须等于 v{version}") + wrong = _refuse_wrong_feature_number(version) + if wrong: + raise SystemExit(wrong) if version.startswith("0.7."): - missing = missing_gates(root, SEVEN_GATES + SEVEN_X_GATES) + missing = missing_gates(root, PHYSICS_GATES) if missing: raise SystemExit(f"拒绝 {version}:缺少 " + ", ".join(missing)) print("0.7 gates present") return 0 - if version != "1.0.0" and tag not in {"v1.0.0", "1.0.0"}: - print(f"skip 1.0 gates: version={version} tag={tag or '-'}") + if version == "1.0.0" or tag in {"v1.0.0", "1.0.0"}: + missing = missing_gates(root, PHYSICS_GATES) + if missing: + raise SystemExit("拒绝 1.0.0:缺少 " + ", ".join(missing)) + print("1.0 identity gates present") return 0 - missing = missing_gates(root) - if missing: - raise SystemExit("拒绝 1.0.0:缺少 " + ", ".join(missing)) - print("1.0 gates present") + if _is_physics_train(root): + raise SystemExit(f"拒绝 {version}:交付物理列车只发 0.7.x") + print(f"skip physics gates: version={version} tag={tag or '-'}") return 0 From 16dece53ec3e31643128bfe02c0750026c343fd6 Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:37:30 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(=E4=BA=A4=E4=BB=98=E7=89=A9=E7=90=86):?= =?UTF-8?q?=20=E8=AE=A9=20Console=20=E5=8F=AA=E8=AF=BB=E5=B7=B2=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=9A=84=E6=9C=AC=E6=9C=BA=E6=9B=B4=E6=96=B0=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/system 读 updates.json,不探测 PATH、不打网络。 空 tools 表示未探测;5 秒总览轮询不拉此接口。 --- CHANGELOG.md | 5 ++ docs/designs/local-web-console.md | 4 +- src/dyro/console/_inspect_worker.py | 2 + src/dyro/console/assets.py | 12 ++--- src/dyro/console/assets/app.js | 81 +++++++++++++++++++++++++++-- src/dyro/console/assets/index.html | 10 ++++ src/dyro/console/assets/styles.css | 3 ++ src/dyro/console/inspection.py | 37 +++++++++++++ src/dyro/console/overview.py | 52 ++++++++++++++++++ src/dyro/console/server.py | 31 ++++++++++- tests/test_console_assets.py | 6 +++ tests/test_console_inspection.py | 35 +++++++++++++ tests/test_console_overview.py | 59 +++++++++++++++++++++ tests/test_console_server.py | 39 +++++++++++++- 14 files changed, 360 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8798f24..df7a96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - Console overview now rolls up `task_status_counts` from readable workspaces only. Unreadable workspaces stay unknown and are not counted as zero. +- Console `GET /api/v1/system` reads the cached update record only. + `tools` stays empty and `tool_inspection=not_inspected`. The shell + fetches it on start and manual refresh, not on the 5s overview poll. + A broken cache is unread and path-free. Empty tools means unprobed, + not "no tools". - Console meta advertises `proofs`. The shell fetches `GET /api/v1/workspaces/{alias}/proofs` only when that capability is present. - Overview summary cards now carry `proof_inspection=not_inspected`. Isolated diff --git a/docs/designs/local-web-console.md b/docs/designs/local-web-console.md index 45dcce3..0522bf4 100644 --- a/docs/designs/local-web-console.md +++ b/docs/designs/local-web-console.md @@ -475,9 +475,9 @@ title 和 branch 是明确的展示字段,但仍受长度、Unicode 控制字 | 方法与路径 | 返回 | | --- | --- | -| `GET /api/v1/meta` | 版本、`surfaces`(`overview` 与 `proofs`;`capabilities` 为兼容别名)、session expiry | +| `GET /api/v1/meta` | 版本、`surfaces`(`overview`、`proofs`、`system`;`capabilities` 为兼容别名)、session expiry | | `GET /api/v1/overview?cursor=...&limit=...` | 已登记 workspace 的分页轻量摘要 | -| `GET /api/v1/system` | 本机工具状态与已缓存更新状态 | +| `GET /api/v1/system` | 只读 `updates.json` 缓存。`tools` 恒为空,`tool_inspection=not_inspected` 表示未探测,不得写成没有工具。不探测 PATH,不发起网络检查。5 秒 overview 轮询不拉此接口。 | | `GET /api/v1/workspaces/{alias}` | 单 workspace 摘要卡,加上同一次 summary 快照的线 / 任务 / 目标清单 | | `GET /api/v1/workspaces/{alias}/proofs` | 独立 Proof inspect;摘要保持未检查 | | `GET /api/v1/workspaces/{alias}/lines/{kind}/{line}` | 单 line 或 hotfix 详情 | diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index 419f342..f526946 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -311,6 +311,8 @@ def main(argv: list[str] | None = None) -> int: # Inspect is its own exec-worker request. Keep git descendants in # this process group so the parent's 8s killpg can reap them. payload = service.inspect_proofs(alias) + elif operation == "system": + payload = service.system() else: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") return _response(payload) diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index df28316..17290a6 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -22,18 +22,18 @@ class ConsoleAsset: ASSET_MANIFEST = { "index.html": ( "text/html; charset=utf-8", - "76a17597706397bad43a0ba84a121e625abb70e30f2f2cf42f61521308cb44f2", - 3138, + "1a5a5c448a700ecef4a44669458143177a65720be203ae8da1800d2a7bf84dad", + 3614, ), "app.js": ( "text/javascript; charset=utf-8", - "7e546606308a9ea85169c2b938b54d81b20018614ca96980615f123fc6675167", - 22848, + "7c7ea211d143d1937575f50b4de830397266ed19a0a82a0c338a4e6bb00d3c7f", + 25520, ), "styles.css": ( "text/css; charset=utf-8", - "1b295330e8a23907a2b48b00778a9ab5a59e4b6af10e691cf6d3113c8d1d2366", - 11333, + "bcde1854e7bcf52439e9a5d79176660193ceca17c8dec5a866d367a54a6a2b6e", + 11419, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index 5f0fc96..54f5206 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -1,6 +1,6 @@ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; const TOKEN_KEY = "dyro.console.bearer"; -const state = { bearer: "", etags: new Map(), timer: null, focus: "", partial: false, surfaces: [] }; +const state = { bearer: "", etags: new Map(), timer: null, focus: "", partial: false, surfaces: [], system: null }; const HEALTH_LABELS = { healthy: "健康", degraded: "需关注", unavailable: "不可用" }; const FRESHNESS_LABELS = { fresh: "新鲜", partial: "部分可用", stale: "待更新" }; const AVAILABILITY_LABELS = { available: "可用", unavailable: "不可用" }; @@ -69,6 +69,12 @@ const ERROR_LABELS = { WORKSPACE_UNAVAILABLE: "工作区当前不可用", SESSION_REJECTED: "本地会话未建立", }; +const UPDATE_KIND_LABELS = { + none: "无已缓存更新", + patch: "有补丁更新", + minor: "有次版本更新", + major: "有主版本更新", +}; const $ = (id) => document.getElementById(id); @@ -479,6 +485,68 @@ function definition(label, value) { return wrapper; } +function firstWarning(payload) { + const warnings = payload && payload.freshness && payload.freshness.warnings; + if (!Array.isArray(warnings) || !warnings.length) return ""; + return text(warnings[0] && warnings[0].code); +} + +function updateKindLabel(kind, latest) { + if (kind === "patch" || kind === "minor" || kind === "major") { + return displayLabel(kind, UPDATE_KIND_LABELS); + } + return text(latest) ? "无更高版本" : "无已缓存更新"; +} + +function renderSystem(payload, failed = false) { + const panel = $("system-panel"); + const note = $("system-note"); + const update = $("system-update"); + if (!panel || !note || !update) return; + if (!hasSurface("system")) { + panel.hidden = true; + return; + } + panel.hidden = false; + if (failed || !payload) { + note.textContent = failed + ? "本机系统暂时不可读取。本页不探测 PATH,也不发起网络检查。" + : "本机系统尚未读取。点刷新后只读已缓存的更新记录,不探测 PATH。"; + update.replaceChildren(); + return; + } + const warning = firstWarning(payload); + const unread = warning === "UPDATE_STATE_UNAVAILABLE"; + note.textContent = unread + ? "更新缓存不可读。本页不探测 PATH,也不发起网络检查。" + : "本页只读已缓存的更新记录,不探测 PATH,也不发起网络检查。"; + const cached = payload.data && payload.data.update ? payload.data.update : {}; + update.replaceChildren( + definition("检查开关", unread ? "—" : (cached.check_enabled ? "已启用" : "已关闭")), + definition("上次检查", unread ? "—" : (text(cached.last_checked_on) || "—")), + definition("缓存最新版", unread ? "—" : (text(cached.latest_version) || "—")), + definition("相对当前版本", unread ? "—" : updateKindLabel(cached.kind, cached.latest_version)), + definition("本机工具", "摘要未探测"), + ); +} + +async function loadSystem() { + if (!hasSurface("system")) { + state.system = null; + renderSystem(null); + return; + } + try { + const payload = await request("/api/v1/system", "system"); + if (payload) state.system = payload; + renderSystem(state.system); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") throw error; + state.system = null; + renderSystem(null, true); + } +} + async function loadWorkspace(alias, silent = false) { if (!SAFE_ID.test(alias)) return; try { @@ -527,13 +595,18 @@ function showError(error) { list.append(notice); } -async function refresh() { +async function refresh({ includeSystem = false } = {}) { try { const payload = await request("/api/v1/overview?limit=100", "overview"); if (payload) { renderOverview(payload); state.partial = Boolean(payload.freshness && payload.freshness.partial); } + if (includeSystem) { + await loadSystem(); + } else { + renderSystem(state.system); + } setStatus( state.partial ? "本地会话已就绪;部分工作区状态未能读取,页面只读。" : "本地会话已就绪;页面只读。", false, @@ -556,7 +629,7 @@ function scheduleRefresh() { } async function start() { - $("refresh").addEventListener("click", async () => { await refresh(); scheduleRefresh(); }); + $("refresh").addEventListener("click", async () => { await refresh({ includeSystem: true }); scheduleRefresh(); }); $("primary-copy").addEventListener("click", () => { const button = $("primary-copy"); const command = text(button.dataset.command); @@ -583,7 +656,7 @@ async function start() { : []; if (!state.focus) state.focus = text(meta.data && meta.data.initial_workspace); } - await refresh(); + await refresh({ includeSystem: true }); scheduleRefresh(); } catch (error) { if (error && error.message === "SESSION_EXPIRED") { diff --git a/src/dyro/console/assets/index.html b/src/dyro/console/assets/index.html index ccb268d..4f752a0 100644 --- a/src/dyro/console/assets/index.html +++ b/src/dyro/console/assets/index.html @@ -59,6 +59,16 @@

工作区

+