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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## Unreleased

- Resolve `--workspace` aliases case-insensitively when exactly one
registered name folds to the same key, and keep that canonical spelling
in outputs. Two registered aliases that differ only by case fail closed
with the colliding names listed; a total miss still suggests nearby
names. Implicit JSON resolve does not stamp a `--workspace` selector.
`workspace default` / `workspace remove` plan and apply share that
policy; remove can still target each exact registered name when twins
collide. Bridge workspace list loads each row by stored root so a
fold-twin does not mark the other stale. Implicit `next` and ready
briefing never advertise a `--workspace` selector that would
fail-close; they use `--root` instead. `console` plan and apply share
that resolve (canonical on a unique fold; fail-closed on twins).
Console overview recommendations omit a colliding `--workspace` ad.
The operator copy path does not invent `--workspace … doctor` when
that field is blank.
- JSON `doctor` / `status` / `next` no longer share a flat 5s observation
deadline with Bridge. Those commands start at the documented 45s ceiling
(not 5s) so a ~50+ worktree workspace that the text path finishes in ~7s
Expand Down
8 changes: 2 additions & 6 deletions src/dyro/bridge/observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ..config import Config
from ..continuation.resolution import (
WorkspaceResolutionError,
load_registered_profile,
resolve_workspace_readonly,
)
from ..continuation.store import get_objective, list_objectives
Expand Down Expand Up @@ -78,12 +79,7 @@ def list_workspaces_observation(*, budget: ReadBudget | None = None) -> dict[str
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
profile = load_registered_profile(record, limits)
items.append(
{
"alias": record.name,
Expand Down
62 changes: 40 additions & 22 deletions src/dyro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@
deadline_repair_commands,
repair_commands,
)
from .continuation.ready_briefing import briefing_command, build_ready_briefing
from .continuation.ready_briefing import (
briefing_command,
build_ready_briefing,
scoped_briefing_command,
)
from .continuation.engine import (
build_scheduler_tick,
render_scheduler_tick_text,
Expand Down Expand Up @@ -343,12 +347,20 @@ def _config(args: argparse.Namespace) -> Config:
cwd=Path.cwd().absolute(),
budget=budget,
)
if (
workspace_arg
and resolved.source is WorkspaceResolutionSource.EXPLICIT
and resolved.registry_alias is not None
):
setattr(args, "workspace_alias", resolved.registry_alias)
setattr(args, "_control_plane_resolution", resolved)
return resolved.profile.config
if root_arg:
root = Path(root_arg).expanduser()
elif workspace_arg:
root = get_workspace(workspace_arg).root
record = get_workspace(workspace_arg)
setattr(args, "workspace_alias", record.name)
root = record.root
else:
interactive = sys.stdin.isatty() and sys.stdout.isatty()
return resolve_workspace(
Expand Down Expand Up @@ -661,9 +673,9 @@ def _timeout_repair_commands(
try:
config = _config(args)
commands = deadline_repair_commands(config, alias, failures)
return commands or [_briefing_command(args, config, "doctor")]
except (DyroError, OSError, ValidationError, TypeError, AttributeError):
commands = [briefing_command(alias, "doctor")]
return commands or [briefing_command(alias, "doctor")]
return [briefing_command(alias, "doctor")]


def _print_json_observation_timeout(args: argparse.Namespace) -> None:
Expand Down Expand Up @@ -714,12 +726,16 @@ def _print_json_observation_timeout(args: argparse.Namespace) -> None:
return
commands = _timeout_repair_commands(args, findings)
failures = [item for item in findings if item.startswith("FAIL")]
try:
diagnostic_commands = [_briefing_command(args, _config(args), "doctor")]
except (DyroError, OSError, ValidationError, TypeError, AttributeError):
diagnostic_commands = [briefing_command(workspace, "doctor")]
_print_control_plane_json(
"next_step",
state="needs_repair",
summary="工作区还不能开始任务。",
commands=commands,
diagnostic_commands=[briefing_command(workspace, "doctor")],
diagnostic_commands=diagnostic_commands,
mutation_available=False,
partial=True,
findings=[
Expand Down Expand Up @@ -811,9 +827,9 @@ def _scoped_command(
def _briefing_command(
args: argparse.Namespace, config: Config, *command: str
) -> str:
"""Scope a read-only briefing command without embedding --root paths."""
"""Scope a read-only briefing command without a fail-closed selector."""
alias = getattr(args, "workspace_alias", None) or config.name
return briefing_command(str(alias), *command)
return scoped_briefing_command(config, str(alias), *command)


def _workspace_ready_briefing(
Expand Down Expand Up @@ -1926,21 +1942,23 @@ def cmd_console(args: argparse.Namespace) -> None:
initial_workspace = getattr(args, "workspace_alias", None)
root_arg = getattr(args, "root", None)
target_root: Path | None = None
if root_arg:
if args.dry_run:
target_root = Path(root_arg).expanduser().absolute()
else:
config = load(Path(root_arg).expanduser())
target_root = config.root
initial_workspace = config.name
elif initial_workspace:
initial_workspace = get_workspace(initial_workspace).name
if args.dry_run:
target_root = Path(root_arg).expanduser().absolute() if root_arg else None
render_console_plan(
port=args.port,
no_open=args.no_open,
initial_workspace=initial_workspace,
target_root=target_root,
)
return
if root_arg:
config = load(Path(root_arg).expanduser())
target_root = config.root
initial_workspace = config.name
elif initial_workspace:
get_workspace(initial_workspace)
launch_console(
port=args.port,
no_open=args.no_open,
Expand Down Expand Up @@ -2022,23 +2040,23 @@ def cmd_workspace_list(args: argparse.Namespace) -> None:


def cmd_workspace_default(args: argparse.Namespace) -> None:
record = get_workspace(args.name)
if args.dry_run:
get_workspace(args.name)
print(f"DRY RUN: 将默认工作区设为 {args.name}")
print(f"DRY RUN: 将默认工作区设为 {record.name}")
return
set_default_workspace(args.name)
print(f"默认工作区:{args.name}")
set_default_workspace(record.name)
print(f"默认工作区:{record.name}")


def cmd_workspace_remove(args: argparse.Namespace) -> None:
get_workspace(args.name)
record = get_workspace(args.name, exact_on_collision=True)
if not args.yes and not args.dry_run:
raise DyroError("移除只会删除全局首页入口,不会删除项目文件;确认后请加 --yes")
if args.dry_run:
print(f"DRY RUN: 将移除工作区入口 {args.name};不会删除项目文件")
print(f"DRY RUN: 将移除工作区入口 {record.name};不会删除项目文件")
return
remove_workspace(args.name)
print(f"已移除工作区入口:{args.name};项目文件未改动")
remove_workspace(record.name)
print(f"已移除工作区入口:{record.name};项目文件未改动")


def _blueprint_document(args: argparse.Namespace):
Expand Down
61 changes: 46 additions & 15 deletions src/dyro/console/_inspect_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
WORKSPACE_MISSING_ROOT,
WORKSPACE_TIMEOUT,
WORKSPACE_UNAVAILABLE,
omit_colliding_workspace_command,
unavailable_workspace_summary,
workspace_root_missing,
)
Expand All @@ -39,31 +40,41 @@
_WORKER_RESPONSE_LIMIT = 2 * 1024 * 1024


def _unavailable_summary(alias: str, code: str) -> dict[str, object]:
return unavailable_workspace_summary(alias, False, reason=code)
def _unavailable_summary(
alias: str, code: str, names: tuple[str, ...] = ()
) -> dict[str, object]:
return unavailable_workspace_summary(alias, False, reason=code, names=names)


def _capture_workspace_summary(
result_queue: Any,
record: WorkspaceRecord,
is_default: bool,
registry: WorkspaceRegistry | None = None,
) -> None:
"""Capture one workspace only, returning a JSON-safe value through IPC."""
names = (
tuple(item.name for item in registry.workspaces)
if registry is not None
else (record.name,)
)
try:
if workspace_root_missing(record.root):
result_queue.put(
{
"summary": _unavailable_summary(record.name, WORKSPACE_MISSING_ROOT),
"summary": _unavailable_summary(
record.name, WORKSPACE_MISSING_ROOT, names
),
"warnings": [WORKSPACE_MISSING_ROOT],
}
)
return
registry = WorkspaceRegistry(
capture_registry = registry or WorkspaceRegistry(
default=record.name if is_default else "",
workspaces=(record,),
)
service = ConsoleOverviewService(
registry_loader=lambda: registry,
registry_loader=lambda: capture_registry,
commands_loader=next_commands,
)
payload = service.workspace(record.name)
Expand All @@ -73,17 +84,23 @@ def _capture_workspace_summary(
except Exception:
result_queue.put(
{
"summary": _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE),
"summary": _unavailable_summary(
record.name, WORKSPACE_UNAVAILABLE, names
),
"warnings": [WORKSPACE_UNAVAILABLE],
}
)


def _parse_child_result(
value: object, record: WorkspaceRecord, *, is_default: bool
value: object,
record: WorkspaceRecord,
*,
is_default: bool,
names: tuple[str, ...] = (),
) -> tuple[dict[str, object], set[str]]:
if not isinstance(value, dict):
return _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE), {
return _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE, names), {
WORKSPACE_UNAVAILABLE
}
summary = value.get("summary")
Expand All @@ -93,13 +110,13 @@ def _parse_child_result(
or not isinstance(warnings, list)
or not all(isinstance(item, str) for item in warnings)
):
return _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE), {
return _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE, names), {
WORKSPACE_UNAVAILABLE
}
copied = dict(summary)
copied["alias"] = record.name
copied["is_default"] = is_default
return copied, set(warnings)
return omit_colliding_workspace_command(copied, names), set(warnings)


def _isolated_summaries(
Expand All @@ -115,9 +132,12 @@ def _isolated_summaries(
summaries: list[dict[str, object]] = []
warnings: set[str] = set()
deadline = time.monotonic() + total_timeout
names = tuple(item.name for item in registry.workspaces)

def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None:
summary, codes = _parse_child_result(value, record, is_default=default)
summary, codes = _parse_child_result(
value, record, is_default=default, names=names
)
summaries.append(summary)
warnings.update(codes)

Expand All @@ -131,7 +151,9 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None:
finish(
record,
{
"summary": _unavailable_summary(record.name, WORKSPACE_TIMEOUT),
"summary": _unavailable_summary(
record.name, WORKSPACE_TIMEOUT, names
),
"warnings": [WORKSPACE_TIMEOUT],
},
default=record.name == registry.default,
Expand All @@ -141,7 +163,9 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None:
finish(
record,
{
"summary": _unavailable_summary(record.name, WORKSPACE_TIMEOUT),
"summary": _unavailable_summary(
record.name, WORKSPACE_TIMEOUT, names
),
"warnings": [WORKSPACE_TIMEOUT],
},
default=record.name == registry.default,
Expand All @@ -153,7 +177,12 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None:
result_queue = context.Queue(maxsize=1)
process = context.Process(
target=_capture_workspace_summary,
args=(result_queue, record, record.name == registry.default),
args=(
result_queue,
record,
record.name == registry.default,
registry,
),
daemon=True,
)
process.start()
Expand Down Expand Up @@ -186,7 +215,9 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None:
finish(
record,
{
"summary": _unavailable_summary(record.name, code),
"summary": _unavailable_summary(
record.name, code, names
),
"warnings": [code],
},
default=record.name == registry.default,
Expand Down
4 changes: 2 additions & 2 deletions src/dyro/console/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ class ConsoleAsset:
),
"app.js": (
"text/javascript; charset=utf-8",
"d3a2787202bd1b42dc924c7b7417dedaf2d3415f1efd5bcfab68b63f010bbe07",
97664,
"859472c4513e7ce0f11ed3cd6bf8e8b8fe5ee1295916999bc5de2417189f60dd",
97461,
),
"styles.css": (
"text/css; charset=utf-8",
Expand Down
19 changes: 7 additions & 12 deletions src/dyro/console/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -534,21 +534,16 @@ function recommendedCommand(summary) {
const unread = unavailableReason(summary);
if (unread === "missing_root" || unread === "read_timeout") return "";
const command = text(summary && summary.recommendation && summary.recommendation.command);
const doctor = `dyro --workspace ${alias} doctor`;
const yes = "--" + "yes";
const push = "--" + "push";
if (workspaceHasFail(summary)) {
if (
command
&& !isBareWorkspaceCommand(command, alias)
&& !command.includes(yes)
&& !command.includes(push)
) {
return command;
}
return doctor;
if (
!command
|| isBareWorkspaceCommand(command, alias)
|| command.includes(yes)
|| command.includes(push)
) {
return "";
}
if (!command || isBareWorkspaceCommand(command, alias)) return "";
return command;
}

Expand Down
2 changes: 2 additions & 0 deletions src/dyro/console/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,8 @@ def _safe_code(value: object) -> bool:

@staticmethod
def _safe_command(command: object, alias: object) -> bool:
if command == "":
return True
if not isinstance(command, str) or not isinstance(alias, str):
return False
escaped_alias = re.escape(alias)
Expand Down
Loading