Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- `dyro next` no longer reports `ready` when doctor has FAIL findings,
including missing-origin-only. JSON `state` is `needs_repair`,
`commands` includes scoped `doctor` (a read; `mutation_available`
stays false), and the human path prints the FAILs instead of
「工作区已就绪」。`dyro start` still treats missing-origin as
non-blocking. Isolated Console uses the same `next.commands` source
instead of an empty loader, so a FAIL workspace is not stuck with
empty commands while production is not.
- Console operator surface: treat doctor FAIL findings as something the
page must show even when `next` reports ready with empty commands.
Overview heading and 现在需要你 surface those FAILs; the primary copy
Expand Down
51 changes: 6 additions & 45 deletions src/dyro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
render_human_attention,
render_human_wave,
)
from .continuation.next_step import bootstrap_repair_applicable, repair_commands
from .continuation.ready_briefing import briefing_command, build_ready_briefing
from .continuation.engine import (
build_scheduler_tick,
Expand Down Expand Up @@ -164,7 +165,6 @@
render_setup_plan,
repository_input_from_path,
sibling_workspace_for,
validate_bootstrap_destination,
)
from .read_limits import ObservationLimits, ReadBudget, ReadLimitCode, ReadLimitError
from .profile import (
Expand Down Expand Up @@ -2525,14 +2525,6 @@ def cmd_start(args: argparse.Namespace) -> None:
)


def _bootstrap_destination_safe(config: Config, relative: str) -> bool:
try:
validate_bootstrap_destination(config, relative)
except (DyroError, OSError):
return False
return True


def _next_push_fields(config: Config) -> dict[str, object]:
return push_policy_fields(config.policy)

Expand Down Expand Up @@ -2581,34 +2573,10 @@ def cmd_next(args: argparse.Namespace) -> None:
budget = _control_plane_budget(args) if args.format == "json" else None
findings = doctor(config, read_budget=budget)
failures = [finding for finding in findings if finding.startswith("FAIL")]
missing_origin_failures = [
finding for finding in failures if is_missing_origin_finding(finding)
]
blocking_failures = [
finding for finding in failures if not is_missing_origin_finding(finding)
]
if blocking_failures:
absent_bootstrap_ids = {
repo_id
for repo_id, repository in config.repositories.items()
if repository.remote
and not (config.root / repository.path).exists()
and not (config.root / repository.path).is_symlink()
and _bootstrap_destination_safe(config, repository.path)
}
expected_bootstrap_failures = {
f"FAIL repository {repo_id}: missing or not Git: "
f"{config.root / config.repositories[repo_id].path}"
for repo_id in absent_bootstrap_ids
}
bootstrap_applicable = (
bool(absent_bootstrap_ids) and set(failures) == expected_bootstrap_failures
)
repair_commands = (
[_briefing_command(args, config, "bootstrap", "--yes")]
if bootstrap_applicable
else []
)
if failures:
alias = getattr(args, "workspace_alias", None) or config.name
commands = repair_commands(config, str(alias), failures)
bootstrap_applicable = bootstrap_repair_applicable(config, failures)
findings = [
_doctor_finding_payload(item, include_paths=False) for item in failures
]
Expand All @@ -2617,7 +2585,7 @@ def cmd_next(args: argparse.Namespace) -> None:
"next_step",
state="needs_repair",
summary="工作区还不能开始任务。",
commands=repair_commands,
commands=commands,
diagnostic_commands=[_briefing_command(args, config, "doctor")],
mutation_available=bootstrap_applicable,
findings=findings,
Expand Down Expand Up @@ -2689,17 +2657,10 @@ def cmd_next(args: argparse.Namespace) -> None:
if briefing is not None:
payload["briefing"] = briefing
payload["diagnostic_commands"] = diagnostic_commands
if missing_origin_failures:
payload["findings"] = [
_doctor_finding_payload(item, include_paths=False)
for item in missing_origin_failures
]
payload.update(_family_unacked_fields(config))
payload.update(_next_push_fields(config))
_print_control_plane_json("next_step", **payload)
return
for finding in missing_origin_failures:
_print_doctor_finding(finding)
if briefing is None:
print("工作区已就绪。可用 dyro start 打开本机已安装的编码工具。")
_print_family_unacked_attention(config)
Expand Down
7 changes: 6 additions & 1 deletion src/dyro/console/_inspect_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from typing import Any

from ..config import load
from ..continuation.next_step import next_commands
from ..hub import WorkspaceRecord, WorkspaceRegistry
from .overview import (
ConsoleOverviewError,
Expand Down Expand Up @@ -61,7 +62,10 @@ def _capture_workspace_summary(
default=record.name if is_default else "",
workspaces=(record,),
)
service = ConsoleOverviewService(registry_loader=lambda: registry)
service = ConsoleOverviewService(
registry_loader=lambda: registry,
commands_loader=next_commands,
)
payload = service.workspace(record.name)
summary = payload["data"]["workspace"]
warnings = [item["code"] for item in payload["freshness"]["warnings"]]
Expand Down Expand Up @@ -282,6 +286,7 @@ def main(argv: list[str] | None = None) -> int:
service_arguments: dict[str, object] = {
"cursor_secret": _secret_from_environment(),
"summary_loader": _isolated_summaries,
"commands_loader": next_commands,
}
if target_registry is not None:
service_arguments["registry_loader"] = lambda: target_registry
Expand Down
10 changes: 9 additions & 1 deletion src/dyro/console/overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ def _capture(
warning_codes.add("DOCTOR_UNAVAILABLE")
commands: list[object] = []
try:
loaded = self._commands_loader(config)
loaded = self._invoke_commands_loader(config, alias)
if isinstance(loaded, list):
commands = loaded
except (DyroError, ValidationError, OSError, UnicodeError, TypeError, AttributeError):
Expand Down Expand Up @@ -848,6 +848,14 @@ def _workspace_attention(
)
return {"counts": counts, "items": items}

def _invoke_commands_loader(self, config: Config, alias: str) -> object:
"""Prefer ``(config, alias)`` so Isolated next.commands match the card."""
loader = self._commands_loader
try:
return loader(config, alias)
except TypeError:
return loader(config)

def _recommendation(
self,
alias: str,
Expand Down
89 changes: 89 additions & 0 deletions src/dyro/continuation/next_step.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Read-only ``next.commands`` projection. Isolated Console may import this."""

from __future__ import annotations

from ..config import Config
from ..errors import DyroError, ValidationError
from ..onboarding import validate_bootstrap_destination
from ..read_limits import ReadBudget
from ..workspace import doctor, list_lines
from .ready_briefing import briefing_command


def next_commands(
config: Config,
alias: str | None = None,
*,
read_budget: ReadBudget | None = None,
) -> list[str]:
"""Same list ``dyro next`` puts in JSON ``commands``.

Isolated workers may call this. It does not import launcher, provider,
or session credentials. Commands never embed ``--root`` paths.
"""
token = alias if isinstance(alias, str) and alias else getattr(config, "name", "")
if not isinstance(token, str) or not token:
return []
try:
findings = doctor(config, read_budget=read_budget)
except (DyroError, ValidationError, OSError, TypeError, AttributeError):
return []
failures = [
item
for item in findings
if isinstance(item, str) and item.startswith("FAIL")
]
if failures:
return repair_commands(config, token, failures)
try:
lines = list_lines(config, read_budget=read_budget)
except (DyroError, ValidationError, OSError, TypeError, AttributeError):
return []
if not lines:
return [briefing_command(token, "line", "create", "dev", "--yes")]
return []


def repair_commands(config: Config, alias: str, failures: list[str]) -> list[str]:
"""Scoped repair command for doctor FAILs. Doctor is a read, not ``--yes``."""
if not failures:
return []
if bootstrap_repair_applicable(config, failures):
return [briefing_command(alias, "bootstrap", "--yes")]
return [briefing_command(alias, "doctor")]


def bootstrap_repair_applicable(config: Config, failures: list[str]) -> bool:
"""True only when every FAIL is a missing repo that bootstrap can clone."""
repositories = getattr(config, "repositories", {})
if not isinstance(repositories, dict) or not failures:
return False
root = getattr(config, "root", None)
if root is None:
return False
absent: set[str] = set()
for repo_id, repository in repositories.items():
remote = getattr(repository, "remote", "")
path = getattr(repository, "path", "")
if not remote or not path:
continue
destination = root / path
try:
present = destination.exists() or destination.is_symlink()
except OSError:
continue
if present:
continue
try:
validate_bootstrap_destination(config, path)
except (DyroError, OSError):
continue
absent.add(str(repo_id))
if not absent:
return False
expected = {
f"FAIL repository {repo_id}: missing or not Git: "
f"{root / repositories[repo_id].path}"
for repo_id in absent
}
return set(failures) == expected
9 changes: 5 additions & 4 deletions src/dyro/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -1315,9 +1315,10 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str
def is_missing_origin_finding(finding: str) -> bool:
"""True only for doctor FAILs that mean origin/<line.branch> is absent.

Join completion, setup post-doctor, start, next, and home-open skip these
so SHA-pinned / local-only lines can exist before the remote-tracking ref
is published. Wrong upstream, wrong branch, missing worktree, common-dir,
and symlink FAILs still fail.
Join completion, setup post-doctor, start, and home-open skip these so
SHA-pinned / local-only lines can exist before the remote-tracking ref
is published. ``dyro next`` and Isolated Console do not: a FAIL is not
ready. Wrong upstream, wrong branch, missing worktree, common-dir, and
symlink FAILs still fail.
"""
return finding.startswith("FAIL ") and _MISSING_ORIGIN_TOKEN in finding
27 changes: 23 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1126,9 +1126,20 @@ def test_control_plane_next_preserves_an_explicit_workspace_selector(self) -> No
)

payload = json.loads(output.getvalue())
self.assertEqual(payload["state"], "ready")
self.assertEqual(payload["commands"], [])
self.assertEqual(payload["state"], "needs_repair")
self.assertNotEqual(payload["state"], "ready")
self.assertEqual(
payload["commands"],
["dyro --workspace selected doctor"],
)
self.assertFalse(payload["mutation_available"])
self.assertTrue(
any(
"missing origin/" in item.get("message", "")
for item in payload.get("findings", [])
),
payload,
)

def test_control_plane_json_runtime_errors_use_one_stable_envelope(self) -> None:
with tempfile.TemporaryDirectory(prefix="dyro-registry-") as registry_home:
Expand Down Expand Up @@ -1259,7 +1270,10 @@ def test_control_plane_next_only_offers_applicable_bootstrap(self) -> None:
self.anchor.rename(self.root / "api-missing")
unavailable = self._read_json("next")
self.assertFalse(unavailable["mutation_available"])
self.assertEqual(unavailable["commands"], [])
self.assertEqual(
unavailable["commands"],
["dyro --workspace test-workspace doctor"],
)
self.assertEqual(
unavailable["diagnostic_commands"],
["dyro --workspace test-workspace doctor"],
Expand Down Expand Up @@ -1305,7 +1319,10 @@ def test_control_plane_next_never_offers_bootstrap_through_symlink_parent(
payload = self._read_json("next")

self.assertFalse(payload["mutation_available"])
self.assertEqual(payload["commands"], [])
self.assertEqual(
payload["commands"],
["dyro --workspace test-workspace doctor"],
)
self.assertFalse((outside / "api").exists())

def test_control_plane_rejects_symlinked_line_and_changeset_manifests(self) -> None:
Expand Down Expand Up @@ -1699,6 +1716,7 @@ def test_objective_explain_json_includes_path_free_briefing(self) -> None:
self.assertIn("下一步:", "\n".join(briefing["lines"]))

def test_next_with_one_live_objective_points_to_follow_up(self) -> None:
publish_origin_branch(self.anchor, "feat/alpha")
self._start_release_objective()
explain = self._read_json("objective", "explain", "release")
payload = self._read_json("next")
Expand Down Expand Up @@ -1744,6 +1762,7 @@ def test_bare_dyro_without_objectives_does_not_invent_a_briefing(self) -> None:
self.assertNotIn("objective attention", text)

def test_next_with_two_live_objectives_does_not_pick_one(self) -> None:
publish_origin_branch(self.anchor, "feat/alpha")
self._start_release_objective()
directory = self.config.task_specs_dir / "TASK-B"
directory.mkdir(parents=True)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_console_inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,42 @@ def test_missing_origin_fail_is_not_ready_or_a_bare_workspace_command(self) -> N
self.assertEqual(card["health"], "degraded")
self.assertNotEqual(card["recommendation"]["reason"], "HOME_GUIDANCE")
self.assertNotIn(str(self.root), repr(overview))
workspace = service.workspace("demo")
self.assertEqual(
workspace["data"]["workspace"]["recommendation"]["command"],
"dyro --workspace demo doctor",
)
self.assertNotEqual(
workspace["data"]["workspace"]["recommendation"]["command"],
"dyro --workspace demo",
)

def test_isolated_summary_worker_passes_next_commands_loader(self) -> None:
from dyro.config import load
from dyro.continuation.next_step import next_commands
from dyro.workspace import create_line

create_line(load(self.root), line_id="core", branch="feat/core", base="main")
seen: dict[str, object] = {}
real = _inspect_worker.ConsoleOverviewService

def spy(*args: object, **kwargs: object):
seen.update(kwargs)
return real(*args, **kwargs)

class _Queue:
def put(self, value: object) -> None:
self.value = value

with patch.object(_inspect_worker, "ConsoleOverviewService", side_effect=spy):
_inspect_worker._capture_workspace_summary(
_Queue(), WorkspaceRecord("demo", self.root), True
)

self.assertIs(seen.get("commands_loader"), next_commands)
commands = next_commands(load(self.root), alias="demo")
self.assertIn("dyro --workspace demo doctor", commands)
self.assertNotIn("dyro --workspace demo", commands)

def test_worker_cannot_serve_or_write_artifacts_via_a_mutation_op(self) -> None:
from dyro.config import load
Expand Down
Loading