From c7796fedc7d6d9fd44ad342ef5c29c63edfbc3ce Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Tue, 15 Sep 2026 17:36:24 +0800 Subject: [PATCH 01/13] feat(cli): support external managed workspaces --- README.cn.md | 14 +- README.md | 15 +- chipcompiler/cli/command_handlers/project.py | 124 ++++++- chipcompiler/cli/commands/project.py | 5 + chipcompiler/cli/commands/workspace.py | 34 +- chipcompiler/cli/core/inputs.py | 9 + chipcompiler/cli/core/invocation.py | 68 +++- chipcompiler/cli/project/run_dispatch.py | 78 +++- chipcompiler/cli/project/run_prepare.py | 27 +- .../cli/project/workspace_location.py | 46 +++ .../cli/project/workspace_registration.py | 157 ++++++++ chipcompiler/project/manifest.py | 41 ++- chipcompiler/project/manifest_write.py | 28 +- docs/development.cn.md | 10 +- docs/development.md | 13 +- docs/specification/cli-design.md | 19 +- test/cli/commands/test_flow_continuation.py | 20 +- test/cli/commands/test_workspace_import.py | 338 ++++++++++++++++++ test/cli/project/test_manifest.py | 65 +++- 19 files changed, 1029 insertions(+), 82 deletions(-) create mode 100644 chipcompiler/cli/project/workspace_location.py create mode 100644 chipcompiler/cli/project/workspace_registration.py create mode 100644 test/cli/commands/test_workspace_import.py diff --git a/README.cn.md b/README.cn.md index 517153ee..88073881 100644 --- a/README.cn.md +++ b/README.cn.md @@ -136,6 +136,18 @@ ecc status --project gcd ecc log --project gcd ``` +默认情况下,具名 workspace 创建在 `/`。如需在项目外部的 +精确目录创建 workspace,请同时提供 workspace ID 和绝对 `--path`: + +```bash +ecc run --project gcd --workspace experiment --path /data/ecc/gcd/experiment +ecc workspace import recovered --project gcd --path /archive/ecc/gcd/recovered +ecc run --project gcd --workspace recovered --resume +``` + +规范路径会登记到 `project.json`;后续命令只需使用 workspace ID,无需重复传入 +`--path`。 + ## CLI 命令 运行 `ecc --help`(或 `ecc --help`)查看完整用法。常用命令: @@ -154,7 +166,7 @@ ecc log --project gcd | `ecc param` | 管理参数覆盖(`list`、`show`、`set`、`unset`、`diff`) | | `ecc pdk` | 管理 PDK 路径(`set-root`、`show`、`unset`) | | `ecc project` | 编辑 `ecc.toml` 中的项目声明(`set`、`unset`、`add`、`remove`、`show`) | -| `ecc workspace` | 从项目配置刷新受管 workspace | +| `ecc workspace` | 导入已有 workspace,或从项目配置刷新受管 workspace | | `ecc signoff` | 检查签核就绪度并导出签核包 | | `ecc report` | 生成设计总结、QoR、签核清单和步骤报告 | | `ecc version` | 显示 ECC 运行时和组件版本 | diff --git a/README.md b/README.md index 06f227c3..28d31aa3 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,19 @@ ecc status --project gcd ecc log --project gcd ``` +By default, named workspaces are created at `/`. To +create one at an exact external directory, provide an absolute `--path` with +the workspace ID: + +```bash +ecc run --project gcd --workspace experiment --path /data/ecc/gcd/experiment +ecc workspace import recovered --project gcd --path /archive/ecc/gcd/recovered +ecc run --project gcd --workspace recovered --resume +``` + +`project.json` records the canonical path, so later commands select the +workspace by ID without repeating `--path`. + ## CLI Commands Run `ecc --help` (or `ecc --help`) for full usage. Common commands: @@ -159,7 +172,7 @@ Run `ecc --help` (or `ecc --help`) for full usage. Common commands: | `ecc param` | Manage parameter overrides (`list`, `show`, `set`, `unset`, `diff`) | | `ecc pdk` | Manage the PDK path (`set-root`, `show`, `unset`) | | `ecc project` | Edit project declarations in `ecc.toml` (`set`, `unset`, `add`, `remove`, `show`) | -| `ecc workspace` | Refresh managed workspaces from project configuration | +| `ecc workspace` | Import existing workspaces or refresh them from project configuration | | `ecc signoff` | Inspect readiness and export the signoff package | | `ecc report` | Write design-summary, QoR, checklist, and step reports | | `ecc version` | Show ECC runtime and component versions | diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 2058c8a2..37ea3c55 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -3,7 +3,13 @@ from typing_extensions import deprecated -from chipcompiler.cli.core.inputs import CheckInput, InitInput, MigrateInput, RunInput +from chipcompiler.cli.core.inputs import ( + CheckInput, + InitInput, + MigrateInput, + RunInput, + WorkspaceImportInput, +) from chipcompiler.cli.core.output import disclosure_cmd from chipcompiler.cli.core.records import error_record from chipcompiler.cli.core.types import CommandContext, CommandResult @@ -204,6 +210,8 @@ def _preflight_environment( from chipcompiler.cli.inspection import env_probe from chipcompiler.rtl2gds import resolve_skip_steps + if preset is None: + return None skip = resolve_skip_steps(flow_config) probes = env_probe.probe_environment(env_probe.probe_components_for_preset(preset, skip=skip)) return _preflight_failures(probes, project, preset) @@ -306,6 +314,102 @@ def refresh_workspace(command_input, ctx: CommandContext) -> CommandResult: return _run_project(refresh_input, ctx, execute_flow=False) +def import_workspace(command_input: WorkspaceImportInput, ctx: CommandContext) -> CommandResult: + """Register an existing workspace without opening or changing it.""" + if ctx.project_state == "legacy": + return CommandResult.err([error_record("legacy_workspace_migration_required")]) + if ctx.manifest_error: + return CommandResult.err( + [ + error_record( + ctx.manifest_error.split(":", 1)[0], + reason=ctx.manifest_error, + ) + ] + ) + cfg = ctx.config + if cfg is None and ctx.project_state != "manifest": + return CommandResult.err( + [ + error_record( + "missing_config", + path=os.path.join(ctx.project_dir, "ecc.toml"), + ) + ] + ) + + if ctx.project_state == "manifest": + from chipcompiler.cli.project import effective_config + + resolved = effective_config.resolve_effective_config(ctx, command_input.workspace, cfg) + if isinstance(resolved, CommandResult): + return resolved + cfg, _flow_config, warnings = resolved + else: + warnings = [] + assert cfg is not None + + from chipcompiler.cli.project.config import resolve_pdk_root + from chipcompiler.cli.project.workspace_registration import ( + WorkspaceRegistrationError, + register_existing_workspace, + ) + + try: + outcome, metadata = register_existing_workspace( + ctx.project_dir, + cfg=cfg, + pdk_root=resolve_pdk_root(cfg), + workspace_id=command_input.workspace, + workspace_path=ctx.run_dir, + ) + except WorkspaceRegistrationError as exc: + return CommandResult.err( + [ + error_record( + exc.code, + workspace_id=command_input.workspace, + workspace=ctx.run_dir, + reason=str(exc), + ) + ] + ) + + if outcome == "conflict": + return CommandResult.err( + [ + error_record( + "workspace_conflict", + workspace_id=command_input.workspace, + workspace=ctx.run_dir, + ) + ] + ) + if outcome not in ("registered", "existing"): + return CommandResult.err( + [ + error_record( + "workspace_registration_failed", + workspace_id=command_input.workspace, + workspace=ctx.run_dir, + ) + ] + ) + + return CommandResult.ok( + warnings + + [ + { + "workspace_id": command_input.workspace, + "registration": "imported" if outcome == "registered" else "already_registered", + "status": metadata.status, + "workspace": ctx.run_dir, + "run_cmd": disclosure_cmd("ecc run", ctx.project, command_input.workspace), + } + ] + ) + + def _run_project( command_input: RunInput, ctx: CommandContext, *, execute_flow: bool ) -> CommandResult: @@ -390,6 +494,8 @@ def error(kind: str, **fields) -> CommandResult: ) flow_config = _attach_skip_steps(flow_config, skip_steps) + assert cfg is not None + flow_builders = rtl2gds_api.get_flow_builders() effective_preset = command_input.preset or cfg.flow_preset if command_input.preset is not None and effective_preset not in flow_builders: @@ -430,12 +536,12 @@ def error(kind: str, **fields) -> CommandResult: if skip_policy is not None: flow_config["skip_steps"] = skip_policy - cli_overrides = {} + cli_overrides: dict[str, object] = {} raw_sets = command_input.param_set if raw_sets: from chipcompiler.cli.project.params import parse_cli_overrides - cli_overrides, set_errors = parse_cli_overrides(raw_sets) + cli_overrides, set_errors = parse_cli_overrides(list(raw_sets)) if set_errors: return CommandResult.err( [ @@ -567,10 +673,14 @@ def error(kind: str, **fields) -> CommandResult: if preflight is not None: return preflight - if not fresh_target and ( - command_input.resume - or command_input.from_step is not None - or command_input.only is not None + if ( + not fresh_target + and command_input.path is None + and ( + command_input.resume + or command_input.from_step is not None + or command_input.only is not None + ) ): return _run_workspace(command_input, ctx) diff --git a/chipcompiler/cli/commands/project.py b/chipcompiler/cli/commands/project.py index ec51f8b6..b5e7640a 100644 --- a/chipcompiler/cli/commands/project.py +++ b/chipcompiler/cli/commands/project.py @@ -68,6 +68,10 @@ def run_cmd( str | None, typer.Option("--workspace", help="Create, select, or resume a managed workspace"), ] = None, + path: Annotated[ + str | None, + typer.Option("--path", help="Exact absolute directory for the managed workspace"), + ] = None, resume: Annotated[ bool, typer.Option("--resume", help="Continue from the first non-successful step"), @@ -126,6 +130,7 @@ def run_cmd( overwrite=overwrite, param_set=tuple(param_set or ()), workspace=workspace, + path=path, resume=resume, from_step=from_step, to_step=to_step, diff --git a/chipcompiler/cli/commands/workspace.py b/chipcompiler/cli/commands/workspace.py index 8805111b..68e678d9 100644 --- a/chipcompiler/cli/commands/workspace.py +++ b/chipcompiler/cli/commands/workspace.py @@ -6,11 +6,41 @@ from chipcompiler.cli.command_handlers import project as project_handlers from chipcompiler.cli.core.apps import create_app -from chipcompiler.cli.core.inputs import WorkspaceRefreshInput, output_options, project_options +from chipcompiler.cli.core.inputs import ( + WorkspaceImportInput, + WorkspaceRefreshInput, + output_options, + project_options, +) from chipcompiler.cli.core.invocation import execute_command from chipcompiler.cli.core.options import PlainOption, ProjectOption -workspace_app = create_app(help="Refresh managed workspaces from project configuration") +workspace_app = create_app(help="Import or refresh managed workspaces") + + +@workspace_app.command("import") +def import_cmd( + *, + workspace: Annotated[str, typer.Argument(help="Workspace ID to register")], + path: Annotated[ + str, + typer.Option("--path", help="Exact absolute directory of an existing workspace"), + ], + project: ProjectOption = None, + plain: PlainOption = False, +) -> None: + """Register an existing workspace without changing or running it. + + The workspace remains at its current directory. Future commands select it + by WORKSPACE through the owning project's `project.json`. + """ + command_input = WorkspaceImportInput( + output=output_options(plain=plain), + project=project_options(project), + workspace=workspace, + path=path, + ) + execute_command("workspace", command_input, project_handlers.import_workspace) @workspace_app.command("refresh") diff --git a/chipcompiler/cli/core/inputs.py b/chipcompiler/cli/core/inputs.py index 63215699..89a5680a 100644 --- a/chipcompiler/cli/core/inputs.py +++ b/chipcompiler/cli/core/inputs.py @@ -38,6 +38,7 @@ class RunInput: overwrite: bool = False param_set: tuple[str, ...] = () workspace: str | None = None + path: str | None = None resume: bool = False from_step: str | None = None to_step: str | None = None @@ -222,6 +223,14 @@ class WorkspaceRefreshInput: workspace: str +@dataclass(frozen=True) +class WorkspaceImportInput: + output: OutputOptions + project: ProjectOptions + workspace: str + path: str + + @dataclass(frozen=True) class MacroSetInput: output: OutputOptions diff --git a/chipcompiler/cli/core/invocation.py b/chipcompiler/cli/core/invocation.py index 642736ee..b083772b 100644 --- a/chipcompiler/cli/core/invocation.py +++ b/chipcompiler/cli/core/invocation.py @@ -5,7 +5,12 @@ import typer -from chipcompiler.cli.core.inputs import OutputOptions, ProjectOptions, RunInput +from chipcompiler.cli.core.inputs import ( + OutputOptions, + ProjectOptions, + RunInput, + WorkspaceImportInput, +) from chipcompiler.cli.core.types import CommandContext, CommandResult, OutputMode from chipcompiler.cli.project.config import ( ConfigUnreadableError, @@ -33,15 +38,19 @@ def output_mode(*, plain: bool) -> OutputMode: def _resolve_manifest_workspace( - project_dir: str, workspace_name: str | None, *, allow_create: bool + project_dir: str, + workspace_name: str | None, + *, + allow_create: bool, + workspace_path: str | None = None, ) -> tuple[str, str | None, str | None]: """Resolve a managed workspace from a project.json manifest. Returns (workspace_dir, workspace_id, error). A run may name a new single-segment workspace; read-only commands may only select declarations. """ - from chipcompiler.cli.project.manifest import load_manifest from chipcompiler.cli.project.run_prepare import invalid_workspace_name + from chipcompiler.project.manifest import load_manifest manifest = load_manifest(project_dir) active = manifest.active_workspaces() @@ -67,10 +76,27 @@ def _resolve_manifest_workspace( match = manifest.find_workspace(workspace_name) if match is not None: + if workspace_path is not None and os.path.realpath(match.workspace_path) != workspace_path: + return ( + match.workspace_path, + match.workspace_id, + f"workspace_id_conflict: workspace {workspace_name!r} is already declared at " + f"{match.workspace_path}", + ) return match.workspace_path, match.workspace_id, None ids = ", ".join(w.workspace_id for w in active) or "(none)" if allow_create: - return os.path.join(project_dir, workspace_name), workspace_name, None + target = workspace_path or os.path.join(project_dir, workspace_name) + if workspace_path is not None: + for workspace in manifest.workspaces: + if os.path.realpath(workspace.workspace_path) == os.path.realpath(target): + return ( + target, + workspace_name, + f"workspace_path_conflict: path is already declared as " + f"{workspace.workspace_id!r}", + ) + return target, workspace_name, None return ( os.path.join(project_dir, workspace_name), workspace_name, @@ -83,6 +109,8 @@ def build_context(command_input: CommandInput) -> CommandContext: project_dir = resolve_project_dir(project) workspace_name = getattr(command_input, "workspace", None) + supplied_workspace_path = getattr(command_input, "path", None) + workspace_path = None config_error = None try: cfg = load_run_config(project_dir) @@ -90,7 +118,7 @@ def build_context(command_input: CommandInput) -> CommandContext: cfg = None config_error = str(exc) - from chipcompiler.cli.project.manifest import ( + from chipcompiler.project.manifest import ( ManifestError, classify_project, ) @@ -98,7 +126,24 @@ def build_context(command_input: CommandInput) -> CommandContext: project_state = classify_project(project_dir) manifest_error = None - if workspace_name is not None: + if supplied_workspace_path is not None and workspace_name is None: + run_dir, run_id = os.path.join(project_dir, "default"), None + manifest_error = "path_requires_workspace: --path requires --workspace" + project_state = "invalid_workspace" + elif supplied_workspace_path is not None: + from chipcompiler.cli.project.workspace_location import ( + WorkspacePathError, + canonical_explicit_workspace_path, + ) + + try: + workspace_path = canonical_explicit_workspace_path(supplied_workspace_path, project_dir) + except WorkspacePathError as exc: + run_dir, run_id = os.path.join(project_dir, "default"), workspace_name + manifest_error = f"{exc.code}: {exc}" + project_state = "invalid_workspace" + + if project_state != "invalid_workspace" and workspace_name is not None: from chipcompiler.cli.project.run_prepare import invalid_workspace_name if invalid_workspace_name(workspace_name): @@ -106,9 +151,9 @@ def build_context(command_input: CommandInput) -> CommandContext: manifest_error = f"invalid_workspace: {workspace_name!r} is not a single workspace name" project_state = "invalid_workspace" else: - run_dir = run_id = None - else: - run_dir = run_id = None + run_dir, run_id = os.path.join(project_dir, workspace_name), workspace_name + elif project_state != "invalid_workspace": + run_dir, run_id = os.path.join(project_dir, "default"), None if project_state == "manifest": # Manifest projects use the manifest workspaces table for discovery @@ -118,14 +163,15 @@ def build_context(command_input: CommandInput) -> CommandContext: run_dir, run_id, manifest_error = _resolve_manifest_workspace( project_dir, workspace_name, - allow_create=isinstance(command_input, RunInput), + allow_create=isinstance(command_input, (RunInput, WorkspaceImportInput)), + workspace_path=workspace_path, ) except ManifestError as exc: run_dir, run_id = os.path.join(project_dir, "default"), workspace_name manifest_error = f"manifest_invalid: {exc}" elif project_state != "invalid_workspace": workspace_id = workspace_name or "default" - run_dir, run_id = os.path.join(project_dir, workspace_id), workspace_id + run_dir, run_id = workspace_path or os.path.join(project_dir, workspace_id), workspace_id mode = output_mode(plain=command_input.output.plain) diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index 87fba100..255b9772 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -64,11 +64,10 @@ def _resolves_as_spelled(path: str, anchor: str) -> bool: def _existing_target_guard(run_dir: str, project_dir: str, run_name: str) -> CommandResult | None: - """Reject an existing run target that escapes the project. + """Reject an existing run target that is linked or malformed. - A symlinked run target (or one whose home/flow.json is itself linked) - must never be executed or mutated in place of a project-owned run: - fail loud instead of touching the external workspace it points at. + External workspaces are valid, but their resolved path must match the + supplied spelling and their metadata boundary must not use symlinks. """ from chipcompiler.cli.core.records import error_record @@ -79,7 +78,7 @@ def _existing_target_guard(run_dir: str, project_dir: str, run_name: str) -> Com "run_target_unsafe", workspace_id=run_name, workspace=run_dir, - reason="existing target is not an ECC workspace directory inside the project", + reason="existing target is not a canonical ECC workspace directory", ) ] ) @@ -110,7 +109,14 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str, ws_lock project_dir = ctx.project_dir backup_path = None - if command_input.overwrite and os.path.lexists(run_dir): + empty_target = False + if os.path.isdir(run_dir) and not os.path.islink(run_dir): + try: + empty_target = not os.listdir(run_dir) + except OSError: + empty_target = False + use_existing_empty_target = command_input.path is not None and empty_target + if (command_input.overwrite or use_existing_empty_target) and os.path.lexists(run_dir): if not _resolves_as_spelled(run_dir, project_dir) or not _is_ecc_run_dir(run_dir): return CommandResult.err( [ @@ -189,7 +195,7 @@ def _stale_project_state(project_dir: str, expected: str) -> CommandResult | Non refuses with a retry hint instead of splitting the project. """ from chipcompiler.cli.core.records import error_record - from chipcompiler.cli.project.manifest import classify_project + from chipcompiler.project.manifest import classify_project if classify_project(project_dir) == expected: return None @@ -310,6 +316,58 @@ def fresh_run( unsafe = _existing_target_guard(run_dir, project_dir, run_name) if unsafe is not None: return unsafe + if not workspace_registered and command_input.path is not None: + from chipcompiler.cli.core.records import error_record + from chipcompiler.cli.project.config import resolve_pdk_root + from chipcompiler.cli.project.workspace_registration import ( + WorkspaceRegistrationError, + register_existing_workspace, + ) + + try: + registration, _metadata = register_existing_workspace( + project_dir, + cfg=cfg, + pdk_root=resolve_pdk_root(cfg), + workspace_id=run_name, + workspace_path=run_dir, + project_lock_held=True, + ) + except WorkspaceRegistrationError as exc: + return CommandResult.err( + [ + error_record( + exc.code, + workspace_id=run_name, + workspace=run_dir, + reason=str(exc), + ) + ] + ) + if registration == "conflict": + return CommandResult.err( + [ + error_record( + "workspace_conflict", + workspace_id=run_name, + workspace=run_dir, + ) + ] + ) + if registration not in ("registered", "existing"): + return CommandResult.err( + [ + error_record( + "workspace_registration_failed", + workspace_id=run_name, + workspace=run_dir, + ) + ] + ) + workspace_registered = True + # This execution must use the imported workspace's own + # persisted range, not the project's fresh-run preset. + cfg.manifest_driven = True else: prepared = _prepare_run_target(command_input, ctx, run_dir, run_name, ws_locks) if isinstance(prepared, CommandResult): @@ -318,7 +376,7 @@ def fresh_run( if not workspace_registered: from chipcompiler.cli.core.records import error_record from chipcompiler.cli.project.config import resolve_pdk_root - from chipcompiler.cli.project.manifest_write import pre_register_workspace + from chipcompiler.project.manifest_write import pre_register_workspace registration = pre_register_workspace( project_dir, @@ -339,7 +397,7 @@ def fresh_run( ) ] ) - if registration != "registered": + if registration not in ("registered", "existing"): _abandon_prepared_target(backup_path, run_dir, owns_target=owns_target) return CommandResult.err( [ @@ -351,7 +409,7 @@ def fresh_run( ] ) workspace_registered = True - created_registration = True + created_registration = registration == "registered" if existing: # Manifest workspaces live outside runs/ — migration never moves # them, so the engine must not pin the shared lock for its whole diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index cfca549e..b9b76f57 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -43,10 +43,11 @@ def resolve_manifest_run_target(command_input, ctx): manifest_invalid, workspace_required, or invalid_workspace. """ from chipcompiler.cli.core.records import error_record - from chipcompiler.cli.project.manifest import load_manifest + from chipcompiler.project.manifest import load_manifest project_dir = ctx.project_dir workspace_name = command_input.workspace + explicit_path = ctx.run_dir if command_input.path is not None else None if ctx.project_state == "virgin": run_name = workspace_name or ctx.run_id or "default" @@ -60,7 +61,7 @@ def resolve_manifest_run_target(command_input, ctx): ) ] ) - return (os.path.join(project_dir, run_name), run_name, False, []) + return (explicit_path or os.path.join(project_dir, run_name), run_name, False, []) if ctx.manifest_error and ctx.manifest_error.startswith("manifest_invalid"): return CommandResult.err([error_record("manifest_invalid", reason=ctx.manifest_error)]) @@ -77,6 +78,19 @@ def resolve_manifest_run_target(command_input, ctx): return (os.path.join(project_dir, "default"), "default", False, []) if match is not None: + if explicit_path is not None and os.path.realpath(match.workspace_path) != os.path.realpath( + explicit_path + ): + return CommandResult.err( + [ + error_record( + "workspace_id_conflict", + workspace_id=workspace_name, + workspace=explicit_path, + reason=f"workspace is already declared at {match.workspace_path}", + ) + ] + ) return (match.workspace_path, match.workspace_id, True, []) if invalid_workspace_name(workspace_name): @@ -93,7 +107,8 @@ def resolve_manifest_run_target(command_input, ctx): # path would operate that workspace under an alias the document never # spelled — bypassing its registration and status write-back. Refuse # and name the declared selector instead. - candidate_real = os.path.realpath(os.path.join(project_dir, workspace_name)) + candidate_path = explicit_path or os.path.join(project_dir, workspace_name) + candidate_real = os.path.realpath(candidate_path) for workspace in manifest.workspaces: if os.path.realpath(workspace.workspace_path) == candidate_real: return CommandResult.err( @@ -106,7 +121,7 @@ def resolve_manifest_run_target(command_input, ctx): ) ] ) - return (os.path.join(project_dir, workspace_name), workspace_name, False, []) + return (candidate_path, workspace_name, False, []) def _workspace_failed_result(run_name: str, run_dir: str, reason: str | None) -> CommandResult: @@ -142,7 +157,7 @@ def _fresh_entry_step_name(cfg, flow_config) -> str | None: def _write_back_status(project_dir: str, run_name: str, status: str, warning_records: list) -> None: """Best-effort manifest status write-back; degrades to a warning.""" from chipcompiler.cli.core.records import warning_record - from chipcompiler.cli.project.manifest_write import write_back_workspace_status + from chipcompiler.project.manifest_write import write_back_workspace_status if not write_back_workspace_status(project_dir, run_name, status): warning_records.append( @@ -317,7 +332,7 @@ def rollback_failed_registration() -> None: elif registration_created and backup_path is not None: # This invocation pre-registered an undeclared workspace and then # restored the previous tree: the fresh entry must not shadow it. - from chipcompiler.cli.project.manifest_write import remove_workspace_registration + from chipcompiler.project.manifest_write import remove_workspace_registration remove_workspace_registration(project_dir, run_name) diff --git a/chipcompiler/cli/project/workspace_location.py b/chipcompiler/cli/project/workspace_location.py new file mode 100644 index 00000000..edd49a32 --- /dev/null +++ b/chipcompiler/cli/project/workspace_location.py @@ -0,0 +1,46 @@ +"""Canonical locations for explicitly placed managed workspaces.""" + +import os + + +class WorkspacePathError(ValueError): + """A user-supplied workspace directory cannot be used safely.""" + + def __init__(self, code: str, reason: str) -> None: + super().__init__(reason) + self.code = code + + +def canonical_explicit_workspace_path(path: str, project_dir: str) -> str: + """Validate and canonicalize a complete workspace directory path.""" + if not os.path.isabs(path): + raise WorkspacePathError( + "workspace_path_not_absolute", + "--path must name the complete absolute workspace directory", + ) + + canonical = os.path.realpath(path) + project = os.path.realpath(project_dir) + legacy_runs = os.path.realpath(os.path.join(project_dir, "runs")) + if canonical in (project, legacy_runs): + raise WorkspacePathError( + "workspace_path_unsafe", + "workspace path must not be the project or legacy runs directory", + ) + + try: + project_is_within_workspace = os.path.commonpath((canonical, project)) == canonical + except ValueError: + project_is_within_workspace = False + if project_is_within_workspace: + raise WorkspacePathError( + "workspace_path_unsafe", + "workspace path must not contain the project directory", + ) + + if not os.path.lexists(canonical) and not os.path.isdir(os.path.dirname(canonical)): + raise WorkspacePathError( + "workspace_path_unsafe", + "the workspace parent directory must already exist", + ) + return canonical diff --git a/chipcompiler/cli/project/workspace_registration.py b/chipcompiler/cli/project/workspace_registration.py new file mode 100644 index 00000000..0a64927e --- /dev/null +++ b/chipcompiler/cli/project/workspace_registration.py @@ -0,0 +1,157 @@ +"""Read-only inspection and manifest registration for existing workspaces.""" + +import os +from contextlib import nullcontext +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path + + +class WorkspaceRegistrationError(ValueError): + """An existing directory cannot be registered as a managed workspace.""" + + def __init__(self, code: str, reason: str) -> None: + super().__init__(reason) + self.code = code + + +@dataclass(frozen=True) +class ExistingWorkspaceMetadata: + flow_config: dict[str, str] + status: str + parameter_patch: dict + + +def is_ecc_workspace_directory(path: str) -> bool: + """Return whether *path* has an unlinked ECC flow/config boundary.""" + if not os.path.isdir(path) or os.path.islink(path): + return False + home = os.path.join(path, "home") + flow_json = os.path.join(home, "flow.json") + params_toml = os.path.join(home, "params.toml") + return ( + not os.path.islink(home) + and not os.path.islink(flow_json) + and not os.path.islink(params_toml) + and os.path.isfile(flow_json) + and os.path.isfile(params_toml) + ) + + +def inspect_existing_workspace( + path: str, cfg=None, *, base_parameters: dict | None = None +) -> ExistingWorkspaceMetadata: + """Inspect an existing workspace without opening or modifying it.""" + if not is_ecc_workspace_directory(path): + raise WorkspaceRegistrationError( + "workspace_not_importable", + "directory does not contain an ECC home/flow.json and home/params.toml", + ) + + from chipcompiler.cli.inspection.discovery import get_run_status, read_flow_json + from chipcompiler.data.workspace_config import flow_range_of, load_workspace_config + + try: + parameters = load_workspace_config(path) + flow_range = flow_range_of(parameters.get("_flow", {})) + except Exception as exc: + raise WorkspaceRegistrationError("workspace_not_importable", str(exc)) from exc + if flow_range is None: + raise WorkspaceRegistrationError( + "workspace_not_importable", "workspace has no persisted flow target" + ) + + flow_data = read_flow_json(path) + if not isinstance(flow_data, dict): + raise WorkspaceRegistrationError( + "workspace_not_importable", "workspace flow.json is missing or malformed" + ) + + if cfg is not None: + workspace_design = str(parameters.get("design") or "").strip() + workspace_pdk = str(parameters.get("pdk") or "").strip() + if workspace_design and cfg.design_name and workspace_design != cfg.design_name: + raise WorkspaceRegistrationError( + "workspace_not_importable", + f"workspace design {workspace_design!r} does not match project design " + f"{cfg.design_name!r}", + ) + if workspace_pdk and cfg.pdk_name and workspace_pdk != cfg.pdk_name: + raise WorkspaceRegistrationError( + "workspace_not_importable", + f"workspace PDK {workspace_pdk!r} does not match project PDK {cfg.pdk_name!r}", + ) + + observed = get_run_status(flow_data) + status = observed if observed in ("success", "failed") else "not_started" + parameter_patch = {} + for key, value in parameters.items(): + if key in { + "_flow", + "pdk", + "pdk_root", + "pdk_config", + "design", + "top_module", + "clock", + "config_overrides", + "workspace_param_overrides", + }: + continue + previous = (base_parameters or {}).get(key) + if previous != value: + parameter_patch[key] = { + "from": deepcopy(previous), + "to": deepcopy(value), + } + return ExistingWorkspaceMetadata( + flow_config={"start_step": flow_range[0], "end_step": flow_range[1]}, + status=status, + parameter_patch=parameter_patch, + ) + + +def register_existing_workspace( + project_dir: str, + *, + cfg, + pdk_root: str, + workspace_id: str, + workspace_path: str, + project_lock_held: bool = False, +) -> tuple[str, ExistingWorkspaceMetadata]: + """Inspect and atomically register an existing workspace. + + Callers already dispatching under the project lock can opt out of taking + it twice. The workspace is inspected once before any lock and again while + locked so an import never binds metadata read from a replaced directory. + """ + from chipcompiler.cli.project import migrate_fs + from chipcompiler.engine.reconcile import _workspace_lock + from chipcompiler.project.manifest import base_design_from_config + from chipcompiler.project.manifest_write import pre_register_workspace + + base_parameters = base_design_from_config(cfg, pdk_root).get("parameters", {}) + inspect_existing_workspace(workspace_path, cfg, base_parameters=base_parameters) + project_lock = ( + nullcontext() + if project_lock_held + else migrate_fs.project_migrate_lock(project_dir, exclusive=False) + ) + with project_lock, _workspace_lock(Path(workspace_path)): + metadata = inspect_existing_workspace( + workspace_path, + cfg, + base_parameters=base_parameters, + ) + outcome = pre_register_workspace( + project_dir, + cfg=cfg, + pdk_root=pdk_root, + workspace_id=workspace_id, + workspace_path=workspace_path, + flow_config=metadata.flow_config, + status=metadata.status, + parameter_patch=metadata.parameter_patch, + ) + return outcome, metadata diff --git a/chipcompiler/project/manifest.py b/chipcompiler/project/manifest.py index 32d8f59f..8264aee2 100644 --- a/chipcompiler/project/manifest.py +++ b/chipcompiler/project/manifest.py @@ -165,11 +165,13 @@ def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> Mani if not workspace_id or not workspace_path: raise ManifestError(f"workspaces[{index}] requires workspace_id and workspace_path") resolved = Path(workspace_path) - if not resolved.is_absolute(): + relative_path = not resolved.is_absolute() + if relative_path: resolved = Path(project_dir) / resolved try: canonical = resolved.resolve() - canonical.relative_to(Path(project_dir).resolve()) + if relative_path: + canonical.relative_to(Path(project_dir).resolve()) except ValueError: raise ManifestError( f"workspaces[{index}] workspace_path escapes the project root: {workspace_path}" @@ -180,6 +182,11 @@ def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> Mani raise ManifestError( f"workspaces[{index}] workspace_path cannot be resolved: {workspace_path}" ) from exc + project = Path(project_dir).resolve() + if canonical in (project, project / "runs") or project.is_relative_to(canonical): + raise ManifestError( + f"workspaces[{index}] workspace_path is a protected project path: {workspace_path}" + ) status = source.get("status") if not isinstance(status, str) or status not in _WORKSPACE_STATUSES: status = "not_started" @@ -258,8 +265,8 @@ def load_manifest(project_dir: str) -> ProjectManifest: Mirrors the GUI parser's contract: schema_version 1 and a workspaces array are required, everything else is default-filled. Raises - ManifestError on parse failure, root_path mismatch, or a workspace - path outside the project root. + ManifestError on parse failure, root_path mismatch, or a relative workspace + path that escapes the project root. Absolute workspace paths may be external. """ path = os.path.join(project_dir, MANIFEST_FILENAME) try: @@ -312,6 +319,27 @@ def load_manifest(project_dir: str) -> ProjectManifest: } _validate_mpc(source.get("mpc")) + workspaces = tuple( + _normalize_workspace_entry(entry, index, project_dir) + for index, entry in enumerate(raw_workspaces) + ) + active_ids: set[str] = set() + active_paths: set[str] = set() + for workspace in workspaces: + if workspace.status == "archived": + continue + if workspace.workspace_id in active_ids: + raise ManifestError( + f"invalid project manifest: duplicate active workspace_id " + f"{workspace.workspace_id!r}" + ) + if workspace.workspace_path in active_paths: + raise ManifestError( + f"invalid project manifest: duplicate active workspace_path " + f"{workspace.workspace_path!r}" + ) + active_ids.add(workspace.workspace_id) + active_paths.add(workspace.workspace_path) return ProjectManifest( project_dir=project_dir, @@ -321,10 +349,7 @@ def load_manifest(project_dir: str) -> ProjectManifest: design_name=design_name, base_design=base_design, objectives=objectives, - workspaces=tuple( - _normalize_workspace_entry(entry, index, project_dir) - for index, entry in enumerate(raw_workspaces) - ), + workspaces=workspaces, qor_baseline=qor_baseline, raw=source, ) diff --git a/chipcompiler/project/manifest_write.py b/chipcompiler/project/manifest_write.py index daec98a2..ac6c46a7 100644 --- a/chipcompiler/project/manifest_write.py +++ b/chipcompiler/project/manifest_write.py @@ -62,13 +62,15 @@ def manifest_workspace_entry( status: str, now: str, skip_steps: list[str] | None = None, + parameter_patch: dict | None = None, ) -> dict: """One complete schema-v1 workspaces[] entry, every field materialized. The single builder for generated manifests and migration previews, so the previewed entry and the applied entry are the same object shape. ``skip_steps`` is materialized only when the workspace carries a - declared policy (an explicit empty list stays []). + declared policy (an explicit empty list stays []). ``parameter_patch`` + is always materialized as an object for imported workspace metadata. """ entry = { "workspace_id": workspace_id, @@ -81,7 +83,7 @@ def manifest_workspace_entry( "status": status, "created_at": now, "updated_at": now, - "parameter_patch": {}, + "parameter_patch": dict(parameter_patch or {}), "metrics_summary": {}, "step_metrics": {}, } @@ -352,6 +354,8 @@ def pre_register_workspace( workspace_id: str, workspace_path: str, flow_config: dict | None, + status: str = "not_started", + parameter_patch: dict | None = None, ) -> str: """Atomically register a fresh managed workspace before filesystem creation. @@ -382,9 +386,10 @@ def pre_register_workspace( workspace_path=workspace_path, start_step=start_step, end_step=end_step, - status="not_started", + status=status, skip_steps=list(declared_skip) if declared_skip is not None else None, ) + document["workspaces"][0]["parameter_patch"] = dict(parameter_patch or {}) if write_manifest_if_absent(project_dir, document): return "registered" # A concurrent creator won the link race: fall through and apply the @@ -400,15 +405,15 @@ def mutate(document: dict) -> None: outcome = "failed" return for entry in workspaces: - if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id: + if not isinstance(entry, dict): continue - if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath( + same_id = entry.get("workspace_id") == workspace_id + same_path = os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath( workspace_path - ): - outcome = "existing" - else: - outcome = "conflict" - return + ) + if same_id or same_path: + outcome = "existing" if same_id and same_path else "conflict" + return workspaces.append( manifest_workspace_entry( workspace_id, @@ -416,9 +421,10 @@ def mutate(document: dict) -> None: workspace_path=workspace_path, start_step=start_step, end_step=end_step, - status="not_started", + status=status, now=now, skip_steps=list(declared_skip) if declared_skip is not None else None, + parameter_patch=parameter_patch, ) ) document["updated_at"] = now diff --git a/docs/development.cn.md b/docs/development.cn.md index cc1edc99..d4bd5fbe 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -291,7 +291,7 @@ chipcompiler/engine/qor_report.py # CLI QoR facade,委托 analysis.qor execute_command("check", command_input, project_handlers.check) ``` 3. `core/invocation.py::execute_command()`(`cli/core/invocation.py`)依次: - - `build_context()`:解析项目目录(`--project`,缺省为 cwd)→ 读项目唯一的 `ecc.toml`(不可读时记入 `config_error`)→ `cli/project/manifest.py::classify_project()` 判定项目形态(manifest / legacy / virgin)。manifest 项目只从 `project.json` workspace 表解析 `--workspace NAME`:唯一活跃 workspace 自动选中,多个时必须选择;新的 `ecc run --workspace NAME` 会在创建文件前登记。`--workspace` 是项目内单路径段名称,不是直接路径。legacy 项目必须先迁移才能 `ecc run`;清单损坏为 `manifest_invalid`。随后由 `--plain` 推导 `OutputMode`,组装成带 `project_state` / `manifest_error` 字段的 `CommandContext`(`cli/core/types.py`)。 + - `build_context()`:解析项目目录(`--project`,缺省为 cwd)→ 读项目唯一的 `ecc.toml`(不可读时记入 `config_error`)→ `cli/project/manifest.py::classify_project()` 判定项目形态(manifest / legacy / virgin)。manifest 项目只从 `project.json` workspace 表解析 `--workspace NAME`:唯一活跃 workspace 自动选中,多个时必须选择;新的 `ecc run --workspace NAME` 会在创建文件前登记。`--workspace` 是单路径段逻辑 ID。run 可选的 `--path` 是完整、规范的绝对目标目录,必须同时显式提供 ID,仅用于创建或登记该 ID;未设置时仍使用 `/`。legacy 项目必须先迁移才能 `ecc run`;清单损坏为 `manifest_invalid`。随后由 `--plain` 推导 `OutputMode`,组装成带 `project_state` / `manifest_error` 字段的 `CommandContext`(`cli/core/types.py`)。 - 调 handler:`handler(command_input, ctx) -> CommandResult`。 - handler 返回后按需追加记录(`_with_legacy_hint` / `_with_config_shadow_hint`):legacy 项目的 `run/check/status` 附加迁移提示(指向 `ecc migrate`);workspace 的 `home/` 同时存在 `params.toml` 与旧 `parameters.json` 时打 `workspace_config_shadowed` 警告(旧 JSON 已失效)。 - 渲染:`rendering/renderers.py::render_command_result()` 先查 `RENDERERS[(render_key, output_mode)]` 定制渲染器,没有则落到通用 `rendering/render.py::render_result()`。 @@ -411,9 +411,15 @@ config_param( `run` 有两条互斥路径(`cli/command_handlers/project.py` 的 `run()` / `_run_workspace()`): -- **新建 workspace**:解析 `[design]` 输入声明、PDK、参数与请求入口步骤;只校验入口步骤所需文件;先原子登记受管名称到 `project.json`(`not_started`);预检工具;在 `/` 调用 `create_workspace`。`create_workspace` 将输入复制到 `origin/` 并产出全部步骤配置,CLI 后续不改写配置。正常新建 flow 用 preset;`--from A --to B` 改用 `rtl2gds.build_flow_range(A, B)` 动态构建包含式规范范围。新范围不能与 `--preset`、`--overwrite`、`--resume`、`--only`、`--force` 组合。 +- **新建 workspace**:解析 `[design]` 输入声明、PDK、参数与请求入口步骤;只校验入口步骤所需文件;先原子登记受管名称到 `project.json`(`not_started`);预检工具;默认在 `/` 调用 `create_workspace`,设置 `--path` 时则使用该精确绝对目录。`create_workspace` 将输入复制到 `origin/` 并产出全部步骤配置,CLI 后续不改写配置。正常新建 flow 用 preset;`--from A --to B` 改用 `rtl2gds.build_flow_range(A, B)` 动态构建包含式规范范围。新范围不能与 `--preset`、`--overwrite`、`--resume`、`--only`、`--force` 组合。 - **已有 workspace**:先由 `chipcompiler/engine/reconcile.py` 把持久化 flow 与目标对齐(前缀 → 追加扩展;超集且全成 → `no_op`;分叉 → `flow_mismatch`),再 `load_workspace` 后由 `chipcompiler.engine.rerun` 的 `run_resume`、`run_from` 或 `run_only` 原地复跑。`--from A --to B` 是已有 flow 的包含式范围,会将其后的步骤状态失效但保留其输出文件。已有 workspace 不会重新预检输入,也不会改写已复制输入或配置。 +`ecc workspace import NAME --path /absolute/workspace` 通过 +`cli/project/workspace_registration.py` 校验持久化的 workspace 身份、flow 范围、 +状态和参数差异,再把规范路径原子登记到 `project.json`。显式 +`ecc run --path` 遇到未登记的已有 workspace 时复用同一服务。登记后所有命令都 +通过 manifest 按 ID 解析外部目录,不再接受第二个路径覆盖。 + 项目 preset 的步骤序列定义在 `chipcompiler/rtl2gds/builder.py`(`build_*_flow()` / `get_flow_builders()`),不在 CLI 层。`build_flow_range()` 对规范的 `build_rtl2gds_flow()` 结果切片,步骤别名和顺序只有一份来源。修改序列时须同步引擎默认 flow、`StepEnum` 与 manifest 范围映射;CLI 只负责参数解析、输入契约、进度渲染选择与结果映射。交互式 TTY 的 `ecc run` 走 `run_flow_with_progress()`,`--plain` 与 GUI 走 `execute()`;两条路径挂同一套 Engineering Snapshot 提交 observer,每完成一步都会更新 `home/engineering-snapshot.json`。 #### 扩展环境探查(doctor / 预检) diff --git a/docs/development.md b/docs/development.md index 682f7c39..ea735f40 100644 --- a/docs/development.md +++ b/docs/development.md @@ -331,7 +331,7 @@ Using `ecc check --project gcd --plain` as the example: execute_command("check", command_input, project_handlers.check) ``` 3. `core/invocation.py::execute_command()` (`cli/core/invocation.py`) then: - - `build_context()`: resolves the project directory (`--project`, defaulting to cwd) → reads its sole `ecc.toml` (an unreadable file is recorded in `config_error`) → classifies the project state via `cli/project/manifest.py::classify_project()` (manifest / legacy / virgin). Manifest projects resolve `--workspace NAME` only through the `project.json` workspaces table: one active workspace auto-selects, multiple ones require the selector, and a new `ecc run --workspace NAME` target is registered before files are created. `--workspace` is a single project-local name, never a direct path. A legacy project must migrate before `ecc run`; a corrupt manifest yields `manifest_invalid`. The context derives `OutputMode` from `--plain` and carries `project_state` / `manifest_error` (`cli/core/types.py`). + - `build_context()`: resolves the project directory (`--project`, defaulting to cwd) → reads its sole `ecc.toml` (an unreadable file is recorded in `config_error`) → classifies the project state via `cli/project/manifest.py::classify_project()` (manifest / legacy / virgin). Manifest projects resolve `--workspace NAME` only through the `project.json` workspaces table: one active workspace auto-selects, multiple ones require the selector, and a new `ecc run --workspace NAME` target is registered before files are created. The workspace selector is a single-segment logical ID. The optional run `--path` is a complete canonical absolute target, requires an explicit ID, and is used only to create or register that ID; without it the target remains `/`. A legacy project must migrate before `ecc run`; a corrupt manifest yields `manifest_invalid`. The context derives `OutputMode` from `--plain` and carries `project_state` / `manifest_error` (`cli/core/types.py`). - Calls the handler: `handler(command_input, ctx) -> CommandResult`. - After the handler, records are appended as needed (`_with_legacy_hint` / `_with_config_shadow_hint`): `run/check/status` on a legacy project carry a migration hint (pointing at `ecc migrate`); when a workspace's `home/` holds both `params.toml` and the legacy `parameters.json`, a `workspace_config_shadowed` warning is emitted (the JSON is inert). - Renders: `rendering/renderers.py::render_command_result()` first looks up a custom renderer in `RENDERERS[(render_key, output_mode)]`, falling back to the generic `rendering/render.py::render_result()`. @@ -513,7 +513,8 @@ in `test/cli/params/`. - **Fresh workspace**: resolve `[design]` input declarations, PDK, parameters, and the requested entry step; validate only that entry step's required files; atomically pre-register the managed name in `project.json` as `not_started`; - preflight tools; call `create_workspace` at `/`. + preflight tools; call `create_workspace` at `/` or + the exact absolute `--path` when supplied. `create_workspace` copies inputs to `origin/` and produces all step configs; the CLI never rewrites those configs afterwards. A normal fresh flow uses a preset. `--from A --to B` instead calls `rtl2gds.build_flow_range(A, B)` to @@ -528,6 +529,14 @@ in `test/cli/params/`. retaining downstream output files. Existing workspaces neither preflight fresh inputs nor rewrite copied inputs/configuration. +`ecc workspace import NAME --path /absolute/workspace` uses +`cli/project/workspace_registration.py` to validate persisted workspace +identity, flow range, status, and parameter differences before atomically +adding the canonical path to `project.json`. The same service is used by an +explicit `ecc run --path` that discovers an unregistered existing workspace. +All later commands resolve the external directory through the manifest and do +not accept a second path override. + Project preset sequences are defined in `chipcompiler/rtl2gds/builder.py` (`build_*_flow()` / `get_flow_builders()`), not in the CLI layer. `build_flow_range()` slices the canonical `build_rtl2gds_flow()` result, so diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index b6fee035..24da686d 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -283,8 +283,10 @@ The command graph follows these rules; new commands must follow them too: - **Naming.** Lowercase single words; multi-word names use kebab-case (`set-root`, `layout-image`). Help strings start with an imperative verb. - **Selectors.** `--workspace NAME` selects a declared or newly-created - project-local workspace and may be combined with `--project`. File-producing - commands use `-o/--output`. + managed workspace and may be combined with `--project`. `ecc run` alone may + add an absolute `--path` to place a new workspace or register an existing + one at that exact directory; omitting it keeps the project-local default. + File-producing commands use `-o/--output`. - **Status vs full evidence.** `ecc status` is the lightweight progress check; `ecc report step` is the full per-step evidence report (features, analysis, checklist). Both are read-only. @@ -353,10 +355,15 @@ step marks downstream steps `Unstart` while retaining their output files. `--only`. A new bounded workspace requires both `--from` and `--to` and cannot combine with `--preset`, `--overwrite`, `--resume`, `--only`, or `--force`. The builder dynamically slices the canonical RTL-to-GDS flow for that range. -`--workspace` is a project-local single path segment and can be combined with -`--project`; no direct workspace paths or run ids are supported. Bare `ecc run` -creates `default` for a project with no workspace, resumes its sole active -workspace, and reports `workspace_required` when several are active. +`--workspace` is a single-segment logical ID and can be combined with +`--project`. `ecc run --workspace NAME --path /absolute/workspace` creates at +or resumes the exact path and records it in `project.json`; `--path` is not +accepted without the ID. An existing workspace can be registered without +running it using `ecc workspace import NAME --path /absolute/workspace`. +After registration all commands select it by ID, including when its directory +is outside the project. Bare `ecc run` creates `default` for a project with no +workspace, resumes its sole active workspace, and reports `workspace_required` +when several are active. ### Parameter Management diff --git a/test/cli/commands/test_flow_continuation.py b/test/cli/commands/test_flow_continuation.py index f531c97d..90f759e9 100644 --- a/test/cli/commands/test_flow_continuation.py +++ b/test/cli/commands/test_flow_continuation.py @@ -371,13 +371,11 @@ def test_existing_run_rejects_symlinked_legacy_target( assert len(errors) == 1 assert (external / "home" / "flow.json").read_bytes() == flow_before - def test_existing_run_rejects_symlinked_manifest_target( + def test_manifest_symlink_target_resolves_to_canonical_external_workspace( self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): - """A declared workspace whose directory is a symlink into an - external tree never reaches the engine: the manifest layer rejects it - (manifest_invalid), with the dispatch ownership guard as the backup - line behind it. The external tree is left untouched either way.""" + """A legacy absolute declaration through a symlink is normalized to + the exact external root and remains read-only during discovery.""" pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") project_dir = create_cli_project(pdk_root=pdk_root) external = tmp_path / "external-ws" @@ -387,15 +385,11 @@ def test_existing_run_rejects_symlinked_manifest_target( _write_manifest_with_workspace(project_dir, run_dir, pdk_root) flow_before = (external / "home" / "flow.json").read_bytes() - rc = cli_main.run(["run", "--project", project_dir, "--plain"]) + rc = cli_main.run(["status", "--project", project_dir, "--plain"]) - assert rc != 0 - errors = [ - r - for r in _records(capsys, plain_records) - if r.get("error") in {"manifest_invalid", "run_target_unsafe"} - ] - assert len(errors) == 1 + assert rc == 0 + records = _records(capsys, plain_records) + assert records[0]["workspace"] == str(external.resolve()) assert (external / "home" / "flow.json").read_bytes() == flow_before def test_flow_exception_marks_manifest_status_failed( diff --git a/test/cli/commands/test_workspace_import.py b/test/cli/commands/test_workspace_import.py new file mode 100644 index 00000000..82bf2dea --- /dev/null +++ b/test/cli/commands/test_workspace_import.py @@ -0,0 +1,338 @@ +import json +from pathlib import Path + +from chipcompiler.cli import main as cli_main +from chipcompiler.data.parameter import Parameters, save_parameter + + +def _existing_workspace( + path: Path, + *, + design: str = "gcd", + pdk: str = "ics55", + state: str = "Success", +) -> None: + home = path / "home" + home.mkdir(parents=True) + (home / "flow.json").write_text( + json.dumps( + { + "steps": [ + { + "name": "Synthesis", + "tool": "yosys", + "state": state, + } + ] + } + ) + ) + assert save_parameter( + Parameters( + path=home / "params.toml", + data={ + "pdk": pdk, + "design": design, + "top_module": design, + "clock": "clk", + "frequency_max": 125, + "max_fanout": 16, + "_flow": {"start": "Synthesis", "end": "Synthesis"}, + }, + ) + ) + + +def test_import_external_workspace_registers_metadata_without_changing_workspace( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "recovered" + _existing_workspace(workspace) + before = { + path.relative_to(workspace): path.read_bytes() + for path in workspace.rglob("*") + if path.is_file() + } + + rc = cli_main.run( + [ + "workspace", + "import", + "recovered", + "--project", + project_dir, + "--path", + str(workspace), + "--plain", + ] + ) + + assert rc == 0 + (record,) = plain_records(capsys.readouterr().out) + assert record["registration"] == "imported" + assert record["status"] == "success" + assert record["workspace"] == str(workspace.resolve()) + manifest = json.loads((Path(project_dir) / "project.json").read_text()) + (entry,) = manifest["workspaces"] + assert entry["workspace_id"] == "recovered" + assert entry["workspace_path"] == str(workspace.resolve()) + assert entry["start_step"] == "Synth" + assert entry["end_step"] == "Synth" + assert entry["parameter_patch"]["frequency_max"] == {"from": 100.0, "to": 125} + assert entry["parameter_patch"]["max_fanout"] == {"from": None, "to": 16} + after = { + path.relative_to(workspace): path.read_bytes() + for path in workspace.rglob("*") + if path.is_file() + } + assert after == before + + +def test_import_is_idempotent_and_external_workspace_resolves_by_id( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "recovered" + _existing_workspace(workspace) + args = [ + "workspace", + "import", + "recovered", + "--project", + project_dir, + "--path", + str(workspace), + "--plain", + ] + assert cli_main.run(args) == 0 + capsys.readouterr() + + assert cli_main.run(args) == 0 + (record,) = [ + record for record in plain_records(capsys.readouterr().out) if "registration" in record + ] + assert record["registration"] == "already_registered" + + assert ( + cli_main.run(["status", "--project", project_dir, "--workspace", "recovered", "--plain"]) + == 0 + ) + records = plain_records(capsys.readouterr().out) + assert any(record.get("workspace") == str(workspace.resolve()) for record in records) + + +def test_import_rejects_invalid_workspace_without_manifest( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "not-a-workspace" + workspace.mkdir(parents=True) + + rc = cli_main.run( + [ + "workspace", + "import", + "bad", + "--project", + project_dir, + "--path", + str(workspace), + "--plain", + ] + ) + + assert rc == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_not_importable" + assert not (Path(project_dir) / "project.json").exists() + + +def test_import_rejects_design_mismatch_without_manifest( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "other-design" + _existing_workspace(workspace, design="other") + + rc = cli_main.run( + [ + "workspace", + "import", + "other", + "--project", + project_dir, + "--path", + str(workspace), + "--plain", + ] + ) + + assert rc == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_not_importable" + assert not (Path(project_dir) / "project.json").exists() + + +def test_import_rejects_id_and_path_conflicts(tmp_path, capsys, create_cli_project, plain_records): + project_dir = create_cli_project() + first = tmp_path / "external" / "first" + second = tmp_path / "external" / "second" + _existing_workspace(first) + _existing_workspace(second) + + def invoke(workspace_id: str, path: Path) -> int: + return cli_main.run( + [ + "workspace", + "import", + workspace_id, + "--project", + project_dir, + "--path", + str(path), + "--plain", + ] + ) + + assert invoke("first", first) == 0 + capsys.readouterr() + assert invoke("first", second) == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_id_conflict" + assert invoke("second", first) == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_path_conflict" + + +def test_explicit_workspace_path_requires_id_and_absolute_path( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + + rc = cli_main.run(["run", "--project", project_dir, "--path", str(tmp_path), "--plain"]) + assert rc == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "path_requires_workspace" + + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--workspace", + "external", + "--path", + "relative/workspace", + "--plain", + ] + ) + assert rc == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_path_not_absolute" + + +def test_run_creates_workspace_at_exact_external_path( + tmp_path, capsys, create_cli_project, flow_mocks +): + project_dir = create_cli_project() + external_parent = tmp_path / "external" + external_parent.mkdir() + workspace = external_parent / "created" + + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--workspace", + "created", + "--path", + str(workspace), + "--plain", + ] + ) + + assert rc == 0 + assert flow_mocks.capture["create_kwargs"]["directory"] == str(workspace.resolve()) + manifest = json.loads((Path(project_dir) / "project.json").read_text()) + assert manifest["workspaces"][0]["workspace_path"] == str(workspace.resolve()) + + +def test_run_path_registers_and_resumes_existing_external_workspace( + tmp_path, capsys, create_cli_project, monkeypatch, plain_records +): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "resume" + _existing_workspace(workspace, state="Unstart") + seen = {"ran": False, "created": False} + + class Flow: + def __init__(self, workspace): + self.workspace = workspace + self.workspace_steps = [] + + def create_step_workspaces(self, *, executable_steps=None): + seen["created"] = executable_steps == {"Synthesis"} + + def run_steps(self, **_kwargs): + seen["ran"] = True + return True + + monkeypatch.setattr( + "chipcompiler.data.load_workspace", + lambda _path: type( + "Workspace", + (), + {"flow": type("FlowData", (), {"path": workspace / "home" / "flow.json"})()}, + )(), + ) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root, overrides=None: None, + ) + + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--workspace", + "resume", + "--path", + str(workspace), + "--resume", + "--plain", + ] + ) + + assert rc == 0 + assert seen == {"ran": True, "created": True} + records = plain_records(capsys.readouterr().out) + assert any(record.get("workspace") == str(workspace.resolve()) for record in records) + manifest = json.loads((Path(project_dir) / "project.json").read_text()) + assert manifest["workspaces"][0]["workspace_path"] == str(workspace.resolve()) + + +def test_run_path_accepts_existing_empty_target(tmp_path, create_cli_project, flow_mocks): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "empty" + workspace.mkdir(parents=True) + + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--workspace", + "empty", + "--path", + str(workspace), + ] + ) + + assert rc == 0 + assert flow_mocks.capture["create_kwargs"]["directory"] == str(workspace.resolve()) + + +def test_run_without_path_keeps_project_local_workspace(tmp_path, create_cli_project, flow_mocks): + project_dir = create_cli_project() + + assert cli_main.run(["run", "--project", project_dir, "--workspace", "local"]) == 0 + + assert flow_mocks.capture["create_kwargs"]["directory"] == str(Path(project_dir) / "local") diff --git a/test/cli/project/test_manifest.py b/test/cli/project/test_manifest.py index 40a82326..d9377761 100644 --- a/test/cli/project/test_manifest.py +++ b/test/cli/project/test_manifest.py @@ -131,18 +131,79 @@ def test_load_manifest_rejects_root_path_mismatch(tmp_path): load_manifest(str(tmp_path)) -def test_load_manifest_rejects_workspace_outside_root(tmp_path): +def test_load_manifest_accepts_absolute_workspace_outside_root(tmp_path): + external = tmp_path.parent / "x" _write_manifest( tmp_path, _minimal_document( tmp_path, - workspaces=[{"workspace_id": "ws", "workspace_path": str(tmp_path.parent / "x")}], + workspaces=[{"workspace_id": "ws", "workspace_path": str(external)}], + ), + ) + + manifest = load_manifest(str(tmp_path)) + + assert manifest.workspaces[0].workspace_path == str(external.resolve()) + + +def test_load_manifest_rejects_relative_workspace_outside_root(tmp_path): + _write_manifest( + tmp_path, + _minimal_document( + tmp_path, + workspaces=[{"workspace_id": "ws", "workspace_path": "../x"}], ), ) with pytest.raises(ManifestError): load_manifest(str(tmp_path)) +@pytest.mark.parametrize("workspace_path", (".", "runs")) +def test_load_manifest_rejects_protected_project_paths(tmp_path, workspace_path): + _write_manifest( + tmp_path, + _minimal_document( + tmp_path, + workspaces=[{"workspace_id": "ws", "workspace_path": workspace_path}], + ), + ) + + with pytest.raises(ManifestError, match="protected project path"): + load_manifest(str(tmp_path)) + + +def test_load_manifest_rejects_workspace_path_containing_project(tmp_path): + project = tmp_path / "project" + project.mkdir() + _write_manifest( + project, + _minimal_document( + project, + workspaces=[{"workspace_id": "ws", "workspace_path": str(tmp_path)}], + ), + ) + + with pytest.raises(ManifestError, match="protected project path"): + load_manifest(str(project)) + + +@pytest.mark.parametrize("duplicate", ("id", "path")) +def test_load_manifest_rejects_duplicate_active_workspace_identity(tmp_path, duplicate): + first = {"workspace_id": "one", "workspace_path": str(tmp_path / "one")} + second = {"workspace_id": "two", "workspace_path": str(tmp_path / "two")} + if duplicate == "id": + second["workspace_id"] = first["workspace_id"] + else: + second["workspace_path"] = first["workspace_path"] + _write_manifest( + tmp_path, + _minimal_document(tmp_path, workspaces=[first, second]), + ) + + with pytest.raises(ManifestError, match=f"duplicate active workspace_{duplicate}"): + load_manifest(str(tmp_path)) + + def test_find_workspace_matches_only_declared_id(tmp_path): _write_manifest( tmp_path, From dd65c641a2c30086c04a29a6a3d24bad5cfd86d7 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Tue, 15 Sep 2026 20:54:18 +0800 Subject: [PATCH 02/13] docs(cli): document external workspace paths --- chipcompiler/docs/ecc-user-guide.cn.md | 47 ++++++++++++++++++++++++-- chipcompiler/docs/ecc-user-guide.en.md | 47 ++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index b9aea986..b0973305 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -80,7 +80,7 @@ uv run ecc --help ## 1. 通用约定 - 全局:`ecc --version`(单行版本号)、`ecc --help`。 -- 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <名称>` 是项目内受管的、非空单路径段名称,不能传文件系统路径。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 +- 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <名称>` 是受管的、非空单路径段名称,始终表示逻辑 workspace ID,不能传文件系统路径。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。`ecc run --path <目录>` 可选地把命名 workspace 放在项目外的指定绝对目录;不设置 `--path` 时仍使用原有的 `/` 布局。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 - 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`rpc serve` 和 `layout-image` 使用各自的协议。 - 退出码:成功 0;业务失败 1(错误记录形如 `[error] error=<机器可读错误码>`)。 - 步骤名(step token)有三套写法,按场景区分: @@ -317,6 +317,7 @@ yosys -Q -T -p "help read_slang" 2>&1 | grep -q "No such command" \ ecc run [OPTIONS] --project TEXT 项目目录(缺省 cwd) --workspace TEXT 创建、选择或续跑一个受管 workspace 名称 + --path TEXT workspace 的完整绝对目录(必须同时指定 --workspace) --resume 从第一个非成功步骤继续 --from TEXT 从一个步骤重跑,或与 --to 配对创建范围 workspace --to TEXT 有界范围的包含式终点(必须与 --from 同用) @@ -328,7 +329,26 @@ ecc run [OPTIONS] --plain 面向脚本的 key=value 输出 ``` -新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 在 `/` 创建 workspace → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 +新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 默认在 `/` 创建 workspace,或在 `--path` 指定的完整目录创建 → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。`--path` 是可选的绝对目录,必须同时显式指定 `--workspace`,不会从目录名推断 workspace ID。外部目录中已有有效 ECC workspace 时,也可以用同一命令登记并续跑。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 + +#### 外部 workspace 路径 + +当 workspace 必须放在项目目录外时使用 `--path`。该选项表示 workspace 的完整目录,不是父目录: + +```bash +# 原有行为:在项目下创建并登记 workspace。 +ecc run --project /projects/gcd --workspace local + +# 在项目外创建并运行受管 workspace。 +ecc run --project /projects/gcd --workspace archive \ + --path /data/ecc-runs/gcd/archive + +# 登记后按 ID 选择外部 workspace,不再需要 --path。 +ecc run --project /projects/gcd --workspace archive --resume +ecc status --project /projects/gcd --workspace archive +``` + +路径必须是绝对路径。`ecc` 只创建最后一级目录,因此父目录必须已存在;已有非空目录必须已经是有效 ECC workspace。项目根目录和 legacy `runs/` 目录是受保护目标,包含项目目录的路径也会被拒绝。同一个 workspace ID 不能重新绑定到另一个路径,已登记给其他 ID 的路径也不能重复使用。要登记已有 workspace 但不执行或修改它,请使用 `ecc workspace import`。 `synthesis_lec` preset 需要默认策略跳过的 LEC,因此本示例的项目先编辑 `ecc.toml`(`sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml` 或手动修改)显式设置 `skip_steps = []`: @@ -419,6 +439,7 @@ ecc run [--workspace NAME] [--resume | --from STEP [--to STEP] | --only STEP [-- - 新 workspace 必须同时给出 `--from` 与 `--to`,动态构建这段包含式 flow; - `--only STEP [--force]`:只跑一步,`--force` 用于该步已成功时强制重跑; - `--resume`、`--only` 与范围选择互斥;新建范围不能与 `--preset`、`--resume`、`--only`、`--force`、`--overwrite` 组合;`--workspace` 可与 `--project` 组合; +- `--path` 只用于创建或续跑一个已命名的 workspace,必须同时指定 `--workspace`,不是只读命令的第二个选择器;登记后所有按 workspace 作用域的命令都按 ID 使用清单中的路径; - **已有 workspace 上的 `--from`/`--only`/`--to` 必须用持久化名**(`home/flow.json` 中的原始名,见第 1 节词表,如 `place`、`CTS`、`Timing optimization`);新建范围(`--from A --to B` 同时给出)才接受小写别名。拼错时报 `unknown_step` 并列出全部可用名: ```console @@ -477,6 +498,12 @@ $ ecc run --workspace a/b # workspace 必须是单段名称,不能是路 | `invalid_workspace` | workspace 名含 `/`、是绝对路径或 `.`/`..`;或目录不是可加载的 workspace | 换合规名称 / 检查目录 | | `workspace_required` | 项目有多个活跃 workspace 但没传 `--workspace` | 按报错列出的名称指定其一 | | `workspace_not_declared` | `--workspace` 名与 `project.json` 声明的 id 不一致(含别名指向已声明路径) | 使用报错中给出的已声明 id | +| `path_requires_workspace` | 未指定 `--workspace` 却使用了 `--path` | 显式提供 workspace ID | +| `workspace_path_not_absolute` | `--path` 不是指向完整 workspace 目录的绝对路径 | 传入绝对 workspace 目录 | +| `workspace_path_unsafe` | 路径是项目根目录、legacy `runs/` 目录、包含项目目录,或父目录不存在 | 选择安全目录,并确保父目录已存在 | +| `workspace_id_conflict` | workspace ID 已登记在另一个路径 | 去掉 `--path` 或使用已登记路径;新路径请换 ID | +| `workspace_path_conflict` | 规范化后的路径已登记给另一个 workspace ID | 使用已登记 ID 或更换目录 | +| `workspace_not_importable` | 目录不是受支持的 ECC workspace,或其 design/PDK 身份与项目不匹配 | 指向该项目的有效 workspace | | `workspace_conflict` | 同名 workspace 已声明在另一个路径 | 换名称 | | `workspace_registration_failed` | 向 `project.json` 登记新 workspace 失败(清单不可写等) | 检查 `project.json` 可读写后重试 | | `legacy_workspace_migration_required` | 在 legacy `runs/` 项目上执行 `ecc run` | 先 `ecc migrate`(提示记录会给出完整命令) | @@ -624,7 +651,21 @@ ecc project unset design.spef ecc project show [KEY] ``` -`ecc workspace refresh NAME --project DIR` 用当前 `ecc.toml` 重建一个已在 `project.json` 声明的 workspace,但不执行 flow。它会替换该 workspace 的复制输入、工具配置、状态和产物;完成后再执行 `ecc run --workspace NAME`。`ecc run --workspace NAME --overwrite` 则是刷新后立即执行的既有快捷方式: +`ecc workspace import WORKSPACE --path DIR --project PROJECT` 把已有 ECC workspace 登记到 `project.json`,不执行 flow、不修改 workspace 内文件,也不移动目录。`WORKSPACE` 是后续命令使用的逻辑 ID;`--path` 必填且必须是完整绝对目录。导入会只读检查持久化 flow、状态、design、PDK 和参数,然后原子地登记路径: + +```bash +ecc workspace import archive \ + --project /projects/gcd \ + --path /data/ecc-runs/gcd/archive + +# 导入后按 ID 选择 workspace。 +ecc status --project /projects/gcd --workspace archive +ecc run --project /projects/gcd --workspace archive --resume +``` + +导入会拒绝格式错误或不兼容的 workspace、重复 ID/路径、受保护路径,以及仍需先执行 `ecc migrate` 的 legacy 项目。如果只有 `ecc.toml` 而还没有 `project.json`,成功导入时会先创建 schema-v1 manifest,再登记该 workspace。 + +`ecc workspace refresh NAME --project DIR` 用当前 `ecc.toml` 重建一个已在 `project.json` 声明的 workspace,但不执行 flow。它会在清单已声明的路径上替换该 workspace 的复制输入、工具配置、状态和产物;完成后再执行 `ecc run --workspace NAME`。`ecc run --workspace NAME --overwrite` 则是刷新后立即执行的既有快捷方式: ```console $ ecc workspace refresh default diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index db19bae1..6ac36557 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -80,7 +80,7 @@ uv run ecc --help ## 1. General conventions - Global: `ecc --version` (single version line), `ecc --help`. -- Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` is a managed, non-empty single path segment in that project, never a filesystem path. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. +- Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` is a managed, non-empty single path segment and always selects the logical workspace ID, never a filesystem path. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. `ecc run --path ` optionally places that named workspace at an exact absolute directory outside the project; without `--path`, the existing `/` layout is unchanged. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. - Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `rpc serve` and `layout-image` use their own protocols instead. - Exit codes: 0 on success; 1 on business failure (error records look like `[error] error=`). - Step tokens come in three vocabularies, distinguished by context: @@ -318,6 +318,7 @@ Notes: ecc run [OPTIONS] --project TEXT project directory (defaults to cwd) --workspace TEXT create, select, or resume a managed workspace name + --path TEXT exact absolute directory for the managed workspace (requires --workspace) --resume continue from the first non-successful step --from TEXT re-execute from a step, or pair with --to for a new range workspace --to TEXT inclusive final step for a bounded range (requires --from) @@ -329,7 +330,26 @@ ecc run [OPTIONS] --plain key=value output for scripting ``` -For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). +For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` by default, or at the exact directory supplied by `--path` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. `--path` is optional, must be an absolute complete workspace directory, and requires an explicit `--workspace`; it never infers the workspace ID from the directory name. An existing valid workspace at an external path can be registered and resumed with the same command. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). + +#### External workspace paths + +Use `--path` when the workspace directory must live outside the project. The option names the complete workspace directory, not a parent directory: + +```bash +# Existing behavior: create and register the workspace below the project. +ecc run --project /projects/gcd --workspace local + +# Create and run a managed workspace outside the project. +ecc run --project /projects/gcd --workspace archive \ + --path /data/ecc-runs/gcd/archive + +# Once registered, select the external workspace by ID; --path is no longer needed. +ecc run --project /projects/gcd --workspace archive --resume +ecc status --project /projects/gcd --workspace archive +``` + +The path must be absolute. `ecc` creates only the final directory, so its parent must already exist; an existing non-empty directory must already be a valid ECC workspace. The project root and its legacy `runs/` directory are protected targets, and a path that contains the project directory is rejected. A workspace ID cannot be rebound to another path, and a path already declared for another ID cannot be reused. Use `ecc workspace import` to register an existing workspace without running or modifying it. The `synthesis_lec` preset requires the LEC the default policy skips, so this example's project edits `ecc.toml` first (`sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml` @@ -422,6 +442,7 @@ ecc run [--workspace NAME] [--resume | --from STEP [--to STEP] | --only STEP [-- - on a new workspace, `--from` and `--to` must be supplied together and dynamically build the inclusive flow range; - `--only STEP [--force]`: run exactly one step; `--force` re-runs it even if it already succeeded; - `--resume`, `--only`, and a range are mutually exclusive; a fresh range cannot be combined with `--preset`, `--resume`, `--only`, `--force`, or `--overwrite`; `--workspace` may be combined with `--project`; +- `--path` is only for creating or resuming a named workspace target. It requires `--workspace` and is not a second selector for read-only commands; after registration, all workspace-scoped commands use the manifest's declared path by ID; - **`--from`/`--only`/`--to` on an existing workspace require the persisted names** (the original names in `home/flow.json`; see the vocabulary in section 1, e.g. `place`, `CTS`, `Timing optimization`); only a fresh range (`--from A --to B` given together) accepts the lowercase aliases. A misspelled name reports `unknown_step` with the full list of available names: ```console @@ -480,6 +501,12 @@ $ ecc run --workspace a/b # a workspace must be a single name, never a path | `invalid_workspace` | the workspace name contains `/`, is an absolute path, or is `.`/`..`; or the directory is not a loadable workspace | use a compliant name / inspect the directory | | `workspace_required` | the project has multiple active workspaces but no `--workspace` was given | pass one of the names listed in the error | | `workspace_not_declared` | the `--workspace` name does not match an id declared in `project.json` (including aliases pointing at a declared path) | use the declared id given in the error | +| `path_requires_workspace` | `--path` was supplied without `--workspace` | provide an explicit workspace ID | +| `workspace_path_not_absolute` | `--path` is not an absolute path to a complete workspace directory | pass an absolute workspace directory | +| `workspace_path_unsafe` | the path is the project root, the legacy `runs/` directory, contains the project directory, or has no existing parent | choose a safe directory whose parent already exists | +| `workspace_id_conflict` | the workspace ID is already declared at a different path | omit `--path` or use the declared path; use another ID for a new path | +| `workspace_path_conflict` | the canonical path is already declared for another workspace ID | use the declared ID or choose another directory | +| `workspace_not_importable` | an existing directory is not a supported ECC workspace, or its design/PDK identity does not match the project | point to a valid workspace for this project | | `workspace_conflict` | a workspace with the same name is already declared at another path | choose a different name | | `workspace_registration_failed` | registering the new workspace in `project.json` failed (unwritable manifest, …) | make `project.json` writable and retry | | `legacy_workspace_migration_required` | `ecc run` on a legacy `runs/` project | run `ecc migrate` first (the hint record carries the full command) | @@ -670,7 +697,21 @@ $ ecc project add design.name x # add/remove exist only for the rtl list rc=1 ``` -`ecc workspace refresh NAME --project DIR` rebuilds a workspace already declared in `project.json` from the current `ecc.toml`, without running the flow. It replaces the workspace's copied inputs, tool configuration, state, and artifacts; run `ecc run --workspace NAME` afterwards. `ecc run --workspace NAME --overwrite` is the existing shortcut that refreshes and immediately re-executes: +`ecc workspace import WORKSPACE --path DIR --project PROJECT` registers an existing ECC workspace in `project.json` without running it, changing files below it, or moving it. `WORKSPACE` is the logical ID used by all later commands; `--path` is required and must be an absolute complete workspace directory. Import inspects the persisted flow, status, design, PDK, and parameter state read-only before atomically registering the path: + +```bash +ecc workspace import archive \ + --project /projects/gcd \ + --path /data/ecc-runs/gcd/archive + +# The imported workspace is now selected by ID. +ecc status --project /projects/gcd --workspace archive +ecc run --project /projects/gcd --workspace archive --resume +``` + +Import rejects malformed or incompatible workspaces, duplicate IDs/paths, protected paths, and legacy projects that still require `ecc migrate`. If an `ecc.toml` project has no `project.json` yet, a successful import creates the schema-v1 manifest before registering the workspace. + +`ecc workspace refresh NAME --project DIR` rebuilds a workspace already declared in `project.json` from the current `ecc.toml`, without running the flow. It replaces the workspace's copied inputs, tool configuration, state, and artifacts at the path already declared in the manifest; run `ecc run --workspace NAME` afterwards. `ecc run --workspace NAME --overwrite` is the existing shortcut that refreshes and immediately re-executes: ```console $ ecc workspace refresh default From a0fea3579444c85b2afff6e8aea8478332143e7d Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Wed, 16 Sep 2026 12:23:36 +0800 Subject: [PATCH 03/13] fix(cli): use workspace selector for external paths --- README.cn.md | 9 +-- README.md | 13 ++-- chipcompiler/cli/command_handlers/project.py | 4 +- chipcompiler/cli/commands/project.py | 10 ++- chipcompiler/cli/core/inputs.py | 1 - chipcompiler/cli/core/invocation.py | 51 ++++++++++++++- chipcompiler/cli/core/types.py | 2 + chipcompiler/cli/project/run_dispatch.py | 4 +- chipcompiler/cli/project/run_prepare.py | 6 +- .../cli/project/workspace_location.py | 2 +- chipcompiler/docs/ecc-user-guide.cn.md | 24 ++++--- chipcompiler/docs/ecc-user-guide.en.md | 24 ++++--- docs/development.cn.md | 6 +- docs/development.md | 11 ++-- docs/specification/cli-design.md | 19 +++--- test/cli/commands/test_manifest_discovery.py | 6 +- .../test_partial_workspace_recovery.py | 2 +- test/cli/commands/test_readonly_workspace.py | 17 +++-- test/cli/commands/test_run.py | 2 +- test/cli/commands/test_workspace_import.py | 65 ++++++++++++++----- test/cli/inspect/test_config.py | 11 ++-- test/cli/test_help_rendering.py | 1 + 22 files changed, 188 insertions(+), 102 deletions(-) diff --git a/README.cn.md b/README.cn.md index 88073881..3863ce68 100644 --- a/README.cn.md +++ b/README.cn.md @@ -137,16 +137,17 @@ ecc log --project gcd ``` 默认情况下,具名 workspace 创建在 `/`。如需在项目外部的 -精确目录创建 workspace,请同时提供 workspace ID 和绝对 `--path`: +精确目录创建或选择 workspace,请把绝对路径直接作为 `--workspace` 选择器: ```bash -ecc run --project gcd --workspace experiment --path /data/ecc/gcd/experiment +ecc run --project gcd --workspace /data/ecc/gcd/experiment ecc workspace import recovered --project gcd --path /archive/ecc/gcd/recovered ecc run --project gcd --workspace recovered --resume ``` -规范路径会登记到 `project.json`;后续命令只需使用 workspace ID,无需重复传入 -`--path`。 +对 `ecc run` 而言,单段字符串是 workspace 名称,继续使用项目内目录;绝对路径 +是项目外 workspace 路径。新路径默认以目录 basename 作为 workspace ID;如果路径已经 +登记,则沿用清单中的 ID。规范路径会登记到 `project.json`,后续可按登记的 ID 选择。 ## CLI 命令 diff --git a/README.md b/README.md index 28d31aa3..36ec4ffc 100644 --- a/README.md +++ b/README.md @@ -142,17 +142,20 @@ ecc log --project gcd ``` By default, named workspaces are created at `/`. To -create one at an exact external directory, provide an absolute `--path` with -the workspace ID: +create or select one at an exact external directory, pass the absolute path as +the `--workspace` selector: ```bash -ecc run --project gcd --workspace experiment --path /data/ecc/gcd/experiment +ecc run --project gcd --workspace /data/ecc/gcd/experiment ecc workspace import recovered --project gcd --path /archive/ecc/gcd/recovered ecc run --project gcd --workspace recovered --resume ``` -`project.json` records the canonical path, so later commands select the -workspace by ID without repeating `--path`. +For `ecc run`, a single-segment value is a workspace name and keeps the +project-local layout; an absolute value is an external workspace path. The +path basename is used as the new workspace ID unless the path is already +registered. `project.json` records the canonical path, so later commands can +select the workspace by its registered ID. ## CLI Commands diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 37ea3c55..020034f4 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -477,7 +477,7 @@ def error(kind: str, **fields) -> CommandResult: from chipcompiler.cli.project.effective_config import flow_config_selects_steps if ctx.project_state == "manifest": - resolved_cfg = effective_config.resolve_effective_config(ctx, command_input.workspace, cfg) + resolved_cfg = effective_config.resolve_effective_config(ctx, ctx.run_id, cfg) if isinstance(resolved_cfg, CommandResult): return resolved_cfg cfg, flow_config, entry_warnings = resolved_cfg @@ -675,7 +675,7 @@ def error(kind: str, **fields) -> CommandResult: if ( not fresh_target - and command_input.path is None + and not ctx.workspace_path_explicit and ( command_input.resume or command_input.from_step is not None diff --git a/chipcompiler/cli/commands/project.py b/chipcompiler/cli/commands/project.py index b5e7640a..6c24bc0a 100644 --- a/chipcompiler/cli/commands/project.py +++ b/chipcompiler/cli/commands/project.py @@ -66,11 +66,10 @@ def run_cmd( overwrite: Annotated[bool, typer.Option("--overwrite")] = False, workspace: Annotated[ str | None, - typer.Option("--workspace", help="Create, select, or resume a managed workspace"), - ] = None, - path: Annotated[ - str | None, - typer.Option("--path", help="Exact absolute directory for the managed workspace"), + typer.Option( + "--workspace", + help="Create, select, or resume a workspace name or absolute path", + ), ] = None, resume: Annotated[ bool, @@ -130,7 +129,6 @@ def run_cmd( overwrite=overwrite, param_set=tuple(param_set or ()), workspace=workspace, - path=path, resume=resume, from_step=from_step, to_step=to_step, diff --git a/chipcompiler/cli/core/inputs.py b/chipcompiler/cli/core/inputs.py index 89a5680a..7f0b3447 100644 --- a/chipcompiler/cli/core/inputs.py +++ b/chipcompiler/cli/core/inputs.py @@ -38,7 +38,6 @@ class RunInput: overwrite: bool = False param_set: tuple[str, ...] = () workspace: str | None = None - path: str | None = None resume: bool = False from_step: str | None = None to_step: str | None = None diff --git a/chipcompiler/cli/core/invocation.py b/chipcompiler/cli/core/invocation.py index b083772b..e9469788 100644 --- a/chipcompiler/cli/core/invocation.py +++ b/chipcompiler/cli/core/invocation.py @@ -43,11 +43,13 @@ def _resolve_manifest_workspace( *, allow_create: bool, workspace_path: str | None = None, + path_selector: bool = False, ) -> tuple[str, str | None, str | None]: """Resolve a managed workspace from a project.json manifest. Returns (workspace_dir, workspace_id, error). A run may name a new - single-segment workspace; read-only commands may only select declarations. + single-segment workspace or select an absolute path; read-only commands + may only select declarations. """ from chipcompiler.cli.project.run_prepare import invalid_workspace_name from chipcompiler.project.manifest import load_manifest @@ -55,6 +57,19 @@ def _resolve_manifest_workspace( manifest = load_manifest(project_dir) active = manifest.active_workspaces() + if path_selector and workspace_path is not None: + canonical_path = os.path.realpath(workspace_path) + path_match = next( + ( + workspace + for workspace in manifest.workspaces + if os.path.realpath(workspace.workspace_path) == canonical_path + ), + None, + ) + if path_match is not None: + return path_match.workspace_path, path_match.workspace_id, None + if workspace_name is None: if len(active) == 1: return active[0].workspace_path, active[0].workspace_id, None @@ -109,8 +124,12 @@ def build_context(command_input: CommandInput) -> CommandContext: project_dir = resolve_project_dir(project) workspace_name = getattr(command_input, "workspace", None) - supplied_workspace_path = getattr(command_input, "path", None) + workspace_path_selector = False + supplied_workspace_path = ( + command_input.path if isinstance(command_input, WorkspaceImportInput) else None + ) workspace_path = None + workspace_path_explicit = False config_error = None try: cfg = load_run_config(project_dir) @@ -126,6 +145,19 @@ def build_context(command_input: CommandInput) -> CommandContext: project_state = classify_project(project_dir) manifest_error = None + # ``ecc run`` and workspace-consuming read-only commands accept an + # absolute path directly as the workspace selector. The explicit import + # command keeps its separate NAME + --path contract so callers can choose + # an ID that differs from the directory basename. + if ( + workspace_name is not None + and os.path.isabs(workspace_name) + and not isinstance(command_input, WorkspaceImportInput) + ): + supplied_workspace_path = workspace_name + workspace_path_selector = True + workspace_path_explicit = True + if supplied_workspace_path is not None and workspace_name is None: run_dir, run_id = os.path.join(project_dir, "default"), None manifest_error = "path_requires_workspace: --path requires --workspace" @@ -138,6 +170,14 @@ def build_context(command_input: CommandInput) -> CommandContext: try: workspace_path = canonical_explicit_workspace_path(supplied_workspace_path, project_dir) + if workspace_path_selector: + workspace_name = os.path.basename(workspace_path) + if not workspace_name: + run_dir, run_id = os.path.join(project_dir, "default"), None + manifest_error = ( + "workspace_path_invalid_id: workspace path must end in a named directory" + ) + project_state = "invalid_workspace" except WorkspacePathError as exc: run_dir, run_id = os.path.join(project_dir, "default"), workspace_name manifest_error = f"{exc.code}: {exc}" @@ -151,7 +191,10 @@ def build_context(command_input: CommandInput) -> CommandContext: manifest_error = f"invalid_workspace: {workspace_name!r} is not a single workspace name" project_state = "invalid_workspace" else: - run_dir, run_id = os.path.join(project_dir, workspace_name), workspace_name + run_dir, run_id = ( + workspace_path or os.path.join(project_dir, workspace_name), + workspace_name, + ) elif project_state != "invalid_workspace": run_dir, run_id = os.path.join(project_dir, "default"), None @@ -165,6 +208,7 @@ def build_context(command_input: CommandInput) -> CommandContext: workspace_name, allow_create=isinstance(command_input, (RunInput, WorkspaceImportInput)), workspace_path=workspace_path, + path_selector=workspace_path_selector, ) except ManifestError as exc: run_dir, run_id = os.path.join(project_dir, "default"), workspace_name @@ -185,6 +229,7 @@ def build_context(command_input: CommandInput) -> CommandContext: config=cfg, project_state=project_state, manifest_error=manifest_error, + workspace_path_explicit=workspace_path_explicit, ) diff --git a/chipcompiler/cli/core/types.py b/chipcompiler/cli/core/types.py index 4b2d744b..1ec11637 100644 --- a/chipcompiler/cli/core/types.py +++ b/chipcompiler/cli/core/types.py @@ -21,6 +21,8 @@ class CommandContext: # project.json manifest state: "manifest" | "legacy" | "virgin" | None project_state: str | None = None manifest_error: str | None = None + # ``--workspace`` may be either a managed ID or an absolute external path. + workspace_path_explicit: bool = False @dataclass(frozen=True) diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index 255b9772..bebd5473 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -115,7 +115,7 @@ def _prepare_run_target(command_input, ctx, run_dir: str, run_name: str, ws_lock empty_target = not os.listdir(run_dir) except OSError: empty_target = False - use_existing_empty_target = command_input.path is not None and empty_target + use_existing_empty_target = ctx.workspace_path_explicit and empty_target if (command_input.overwrite or use_existing_empty_target) and os.path.lexists(run_dir): if not _resolves_as_spelled(run_dir, project_dir) or not _is_ecc_run_dir(run_dir): return CommandResult.err( @@ -316,7 +316,7 @@ def fresh_run( unsafe = _existing_target_guard(run_dir, project_dir, run_name) if unsafe is not None: return unsafe - if not workspace_registered and command_input.path is not None: + if not workspace_registered and ctx.workspace_path_explicit: from chipcompiler.cli.core.records import error_record from chipcompiler.cli.project.config import resolve_pdk_root from chipcompiler.cli.project.workspace_registration import ( diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index b9b76f57..9791bd3b 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -46,11 +46,11 @@ def resolve_manifest_run_target(command_input, ctx): from chipcompiler.project.manifest import load_manifest project_dir = ctx.project_dir - workspace_name = command_input.workspace - explicit_path = ctx.run_dir if command_input.path is not None else None + workspace_name = ctx.run_id + explicit_path = ctx.run_dir if ctx.workspace_path_explicit else None if ctx.project_state == "virgin": - run_name = workspace_name or ctx.run_id or "default" + run_name = workspace_name or "default" if invalid_workspace_name(run_name): return CommandResult.err( [ diff --git a/chipcompiler/cli/project/workspace_location.py b/chipcompiler/cli/project/workspace_location.py index edd49a32..2e712a5a 100644 --- a/chipcompiler/cli/project/workspace_location.py +++ b/chipcompiler/cli/project/workspace_location.py @@ -16,7 +16,7 @@ def canonical_explicit_workspace_path(path: str, project_dir: str) -> str: if not os.path.isabs(path): raise WorkspacePathError( "workspace_path_not_absolute", - "--path must name the complete absolute workspace directory", + "workspace path must name the complete absolute workspace directory", ) canonical = os.path.realpath(path) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index b0973305..b35ab81a 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -80,7 +80,7 @@ uv run ecc --help ## 1. 通用约定 - 全局:`ecc --version`(单行版本号)、`ecc --help`。 -- 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <名称>` 是受管的、非空单路径段名称,始终表示逻辑 workspace ID,不能传文件系统路径。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。`ecc run --path <目录>` 可选地把命名 workspace 放在项目外的指定绝对目录;不设置 `--path` 时仍使用原有的 `/` 布局。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 +- 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <选择器>` 可以是受管的非空单段名称,也可以是完整绝对路径。名称继续使用项目内的 `/` 布局;绝对路径创建或选择项目外 workspace,新路径默认以目录 basename 作为 workspace ID,已登记路径沿用清单中的 ID。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 - 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`rpc serve` 和 `layout-image` 使用各自的协议。 - 退出码:成功 0;业务失败 1(错误记录形如 `[error] error=<机器可读错误码>`)。 - 步骤名(step token)有三套写法,按场景区分: @@ -316,8 +316,7 @@ yosys -Q -T -p "help read_slang" 2>&1 | grep -q "No such command" \ ```bash ecc run [OPTIONS] --project TEXT 项目目录(缺省 cwd) - --workspace TEXT 创建、选择或续跑一个受管 workspace 名称 - --path TEXT workspace 的完整绝对目录(必须同时指定 --workspace) + --workspace TEXT 创建、选择或续跑 workspace 名称或绝对路径 --resume 从第一个非成功步骤继续 --from TEXT 从一个步骤重跑,或与 --to 配对创建范围 workspace --to TEXT 有界范围的包含式终点(必须与 --from 同用) @@ -329,21 +328,21 @@ ecc run [OPTIONS] --plain 面向脚本的 key=value 输出 ``` -新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 默认在 `/` 创建 workspace,或在 `--path` 指定的完整目录创建 → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。`--path` 是可选的绝对目录,必须同时显式指定 `--workspace`,不会从目录名推断 workspace ID。外部目录中已有有效 ECC workspace 时,也可以用同一命令登记并续跑。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 +新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → `--workspace` 是名称时默认在 `/` 创建,是绝对路径时在该完整目录创建 → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。绝对路径必须是完整的项目外目录且父目录已存在;新路径以 basename 作为 workspace ID,已登记路径沿用清单中的 ID。外部目录中已有有效 ECC workspace 时,也可以用同一命令登记并续跑。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 #### 外部 workspace 路径 -当 workspace 必须放在项目目录外时使用 `--path`。该选项表示 workspace 的完整目录,不是父目录: +当 workspace 必须放在项目目录外时,把完整绝对目录直接作为 `--workspace` 选择器。它表示 workspace 的完整目录,不是父目录: ```bash # 原有行为:在项目下创建并登记 workspace。 ecc run --project /projects/gcd --workspace local # 在项目外创建并运行受管 workspace。 -ecc run --project /projects/gcd --workspace archive \ - --path /data/ecc-runs/gcd/archive +ecc run --project /projects/gcd \ + --workspace /data/ecc-runs/gcd/archive -# 登记后按 ID 选择外部 workspace,不再需要 --path。 +# 登记后按清单中的 ID 选择外部 workspace。 ecc run --project /projects/gcd --workspace archive --resume ecc status --project /projects/gcd --workspace archive ``` @@ -439,7 +438,7 @@ ecc run [--workspace NAME] [--resume | --from STEP [--to STEP] | --only STEP [-- - 新 workspace 必须同时给出 `--from` 与 `--to`,动态构建这段包含式 flow; - `--only STEP [--force]`:只跑一步,`--force` 用于该步已成功时强制重跑; - `--resume`、`--only` 与范围选择互斥;新建范围不能与 `--preset`、`--resume`、`--only`、`--force`、`--overwrite` 组合;`--workspace` 可与 `--project` 组合; -- `--path` 只用于创建或续跑一个已命名的 workspace,必须同时指定 `--workspace`,不是只读命令的第二个选择器;登记后所有按 workspace 作用域的命令都按 ID 使用清单中的路径; +- `--workspace` 可以是单段名称或绝对路径。绝对路径用于创建或登记项目外目标;登记后所有按 workspace 作用域的命令都可以使用清单中的 ID; - **已有 workspace 上的 `--from`/`--only`/`--to` 必须用持久化名**(`home/flow.json` 中的原始名,见第 1 节词表,如 `place`、`CTS`、`Timing optimization`);新建范围(`--from A --to B` 同时给出)才接受小写别名。拼错时报 `unknown_step` 并列出全部可用名: ```console @@ -495,13 +494,12 @@ $ ecc run --workspace a/b # workspace 必须是单段名称,不能是路 |---|---|---| | `run_exists` | 目标目录已存在但不是有效 ECC workspace(无 `home/flow.json`) | `--overwrite`(有安全校验)或换 `--workspace` | | `overwrite_refused` | `--overwrite` 的目标不是真正的 ECC workspace 目录 | 人工确认目录内容后手动清理 | -| `invalid_workspace` | workspace 名含 `/`、是绝对路径或 `.`/`..`;或目录不是可加载的 workspace | 换合规名称 / 检查目录 | +| `invalid_workspace` | workspace 名含 `/`、是相对路径或 `.`/`..`;或目录不是可加载的 workspace | 使用单段名称或完整绝对路径 / 检查目录 | | `workspace_required` | 项目有多个活跃 workspace 但没传 `--workspace` | 按报错列出的名称指定其一 | | `workspace_not_declared` | `--workspace` 名与 `project.json` 声明的 id 不一致(含别名指向已声明路径) | 使用报错中给出的已声明 id | -| `path_requires_workspace` | 未指定 `--workspace` 却使用了 `--path` | 显式提供 workspace ID | -| `workspace_path_not_absolute` | `--path` 不是指向完整 workspace 目录的绝对路径 | 传入绝对 workspace 目录 | +| `workspace_path_not_absolute` | 导入路径不是绝对路径 | 为 `workspace import` 传入绝对 workspace 目录 | | `workspace_path_unsafe` | 路径是项目根目录、legacy `runs/` 目录、包含项目目录,或父目录不存在 | 选择安全目录,并确保父目录已存在 | -| `workspace_id_conflict` | workspace ID 已登记在另一个路径 | 去掉 `--path` 或使用已登记路径;新路径请换 ID | +| `workspace_id_conflict` | workspace ID 已登记在另一个路径 | 使用已登记的 ID/路径,或更换目录 basename | | `workspace_path_conflict` | 规范化后的路径已登记给另一个 workspace ID | 使用已登记 ID 或更换目录 | | `workspace_not_importable` | 目录不是受支持的 ECC workspace,或其 design/PDK 身份与项目不匹配 | 指向该项目的有效 workspace | | `workspace_conflict` | 同名 workspace 已声明在另一个路径 | 换名称 | diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 6ac36557..79963972 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -80,7 +80,7 @@ uv run ecc --help ## 1. General conventions - Global: `ecc --version` (single version line), `ecc --help`. -- Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` is a managed, non-empty single path segment and always selects the logical workspace ID, never a filesystem path. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. `ecc run --path ` optionally places that named workspace at an exact absolute directory outside the project; without `--path`, the existing `/` layout is unchanged. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. +- Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` accepts either a managed, non-empty single-segment name or a complete absolute filesystem path. A name keeps the project-local `/` layout; an absolute path creates or selects an external workspace and uses its basename as the new ID unless the path is already registered. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. - Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `rpc serve` and `layout-image` use their own protocols instead. - Exit codes: 0 on success; 1 on business failure (error records look like `[error] error=`). - Step tokens come in three vocabularies, distinguished by context: @@ -317,8 +317,7 @@ Notes: ```bash ecc run [OPTIONS] --project TEXT project directory (defaults to cwd) - --workspace TEXT create, select, or resume a managed workspace name - --path TEXT exact absolute directory for the managed workspace (requires --workspace) + --workspace TEXT create, select, or resume a workspace name or absolute path --resume continue from the first non-successful step --from TEXT re-execute from a step, or pair with --to for a new range workspace --to TEXT inclusive final step for a bounded range (requires --from) @@ -330,21 +329,21 @@ ecc run [OPTIONS] --plain key=value output for scripting ``` -For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` by default, or at the exact directory supplied by `--path` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. `--path` is optional, must be an absolute complete workspace directory, and requires an explicit `--workspace`; it never infers the workspace ID from the directory name. An existing valid workspace at an external path can be registered and resumed with the same command. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). +For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` when the selector is a name, or at the exact absolute path when the selector is a path → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. An absolute workspace selector must be a complete external directory whose parent already exists; its basename becomes the new workspace ID unless the path is already registered. An existing valid workspace at an external path can be registered and resumed with the same command. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). #### External workspace paths -Use `--path` when the workspace directory must live outside the project. The option names the complete workspace directory, not a parent directory: +Pass an absolute path directly to `--workspace` when the workspace directory must live outside the project. The selector names the complete workspace directory, not a parent directory: ```bash # Existing behavior: create and register the workspace below the project. ecc run --project /projects/gcd --workspace local # Create and run a managed workspace outside the project. -ecc run --project /projects/gcd --workspace archive \ - --path /data/ecc-runs/gcd/archive +ecc run --project /projects/gcd \ + --workspace /data/ecc-runs/gcd/archive -# Once registered, select the external workspace by ID; --path is no longer needed. +# Once registered, select the external workspace by its registered ID. ecc run --project /projects/gcd --workspace archive --resume ecc status --project /projects/gcd --workspace archive ``` @@ -442,7 +441,7 @@ ecc run [--workspace NAME] [--resume | --from STEP [--to STEP] | --only STEP [-- - on a new workspace, `--from` and `--to` must be supplied together and dynamically build the inclusive flow range; - `--only STEP [--force]`: run exactly one step; `--force` re-runs it even if it already succeeded; - `--resume`, `--only`, and a range are mutually exclusive; a fresh range cannot be combined with `--preset`, `--resume`, `--only`, `--force`, or `--overwrite`; `--workspace` may be combined with `--project`; -- `--path` is only for creating or resuming a named workspace target. It requires `--workspace` and is not a second selector for read-only commands; after registration, all workspace-scoped commands use the manifest's declared path by ID; +- `--workspace` accepts either a single-segment name or an absolute path. Absolute paths create or register an external target; after registration, all workspace-scoped commands can use the manifest's declared ID; - **`--from`/`--only`/`--to` on an existing workspace require the persisted names** (the original names in `home/flow.json`; see the vocabulary in section 1, e.g. `place`, `CTS`, `Timing optimization`); only a fresh range (`--from A --to B` given together) accepts the lowercase aliases. A misspelled name reports `unknown_step` with the full list of available names: ```console @@ -498,13 +497,12 @@ $ ecc run --workspace a/b # a workspace must be a single name, never a path |---|---|---| | `run_exists` | the target directory already exists but is not a valid ECC workspace (no `home/flow.json`) | `--overwrite` (with safety checks) or a different `--workspace` | | `overwrite_refused` | the `--overwrite` target is not a genuine ECC workspace directory | inspect the directory contents and clean it up manually | -| `invalid_workspace` | the workspace name contains `/`, is an absolute path, or is `.`/`..`; or the directory is not a loadable workspace | use a compliant name / inspect the directory | +| `invalid_workspace` | the workspace name contains `/`, is a relative path, or is `.`/`..`; or the directory is not a loadable workspace | use a single-segment name or complete absolute path / inspect the directory | | `workspace_required` | the project has multiple active workspaces but no `--workspace` was given | pass one of the names listed in the error | | `workspace_not_declared` | the `--workspace` name does not match an id declared in `project.json` (including aliases pointing at a declared path) | use the declared id given in the error | -| `path_requires_workspace` | `--path` was supplied without `--workspace` | provide an explicit workspace ID | -| `workspace_path_not_absolute` | `--path` is not an absolute path to a complete workspace directory | pass an absolute workspace directory | +| `workspace_path_not_absolute` | the import path is not absolute | pass an absolute workspace directory to `workspace import` | | `workspace_path_unsafe` | the path is the project root, the legacy `runs/` directory, contains the project directory, or has no existing parent | choose a safe directory whose parent already exists | -| `workspace_id_conflict` | the workspace ID is already declared at a different path | omit `--path` or use the declared path; use another ID for a new path | +| `workspace_id_conflict` | the workspace ID is already declared at a different path | use the declared ID/path or choose another directory basename | | `workspace_path_conflict` | the canonical path is already declared for another workspace ID | use the declared ID or choose another directory | | `workspace_not_importable` | an existing directory is not a supported ECC workspace, or its design/PDK identity does not match the project | point to a valid workspace for this project | | `workspace_conflict` | a workspace with the same name is already declared at another path | choose a different name | diff --git a/docs/development.cn.md b/docs/development.cn.md index d4bd5fbe..4001a61f 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -291,7 +291,7 @@ chipcompiler/engine/qor_report.py # CLI QoR facade,委托 analysis.qor execute_command("check", command_input, project_handlers.check) ``` 3. `core/invocation.py::execute_command()`(`cli/core/invocation.py`)依次: - - `build_context()`:解析项目目录(`--project`,缺省为 cwd)→ 读项目唯一的 `ecc.toml`(不可读时记入 `config_error`)→ `cli/project/manifest.py::classify_project()` 判定项目形态(manifest / legacy / virgin)。manifest 项目只从 `project.json` workspace 表解析 `--workspace NAME`:唯一活跃 workspace 自动选中,多个时必须选择;新的 `ecc run --workspace NAME` 会在创建文件前登记。`--workspace` 是单路径段逻辑 ID。run 可选的 `--path` 是完整、规范的绝对目标目录,必须同时显式提供 ID,仅用于创建或登记该 ID;未设置时仍使用 `/`。legacy 项目必须先迁移才能 `ecc run`;清单损坏为 `manifest_invalid`。随后由 `--plain` 推导 `OutputMode`,组装成带 `project_state` / `manifest_error` 字段的 `CommandContext`(`cli/core/types.py`)。 + - `build_context()`:解析项目目录(`--project`,缺省为 cwd)→ 读项目唯一的 `ecc.toml`(不可读时记入 `config_error`)→ `cli/project/manifest.py::classify_project()` 判定项目形态(manifest / legacy / virgin)。manifest 项目用单段 `--workspace NAME` 从 `project.json` workspace 表解析;绝对 `--workspace PATH` 则先规范化为项目外路径,并优先按路径匹配,再按 basename 推导新 ID。唯一活跃 workspace 自动选中,多个时必须选择;新的 run workspace 会在创建文件前登记。legacy 项目必须先迁移才能 `ecc run`;清单损坏为 `manifest_invalid`。随后由 `--plain` 推导 `OutputMode`,并在 `CommandContext`(`cli/core/types.py`)中记录是否为显式路径选择器以及 `project_state` / `manifest_error`。 - 调 handler:`handler(command_input, ctx) -> CommandResult`。 - handler 返回后按需追加记录(`_with_legacy_hint` / `_with_config_shadow_hint`):legacy 项目的 `run/check/status` 附加迁移提示(指向 `ecc migrate`);workspace 的 `home/` 同时存在 `params.toml` 与旧 `parameters.json` 时打 `workspace_config_shadowed` 警告(旧 JSON 已失效)。 - 渲染:`rendering/renderers.py::render_command_result()` 先查 `RENDERERS[(render_key, output_mode)]` 定制渲染器,没有则落到通用 `rendering/render.py::render_result()`。 @@ -411,13 +411,13 @@ config_param( `run` 有两条互斥路径(`cli/command_handlers/project.py` 的 `run()` / `_run_workspace()`): -- **新建 workspace**:解析 `[design]` 输入声明、PDK、参数与请求入口步骤;只校验入口步骤所需文件;先原子登记受管名称到 `project.json`(`not_started`);预检工具;默认在 `/` 调用 `create_workspace`,设置 `--path` 时则使用该精确绝对目录。`create_workspace` 将输入复制到 `origin/` 并产出全部步骤配置,CLI 后续不改写配置。正常新建 flow 用 preset;`--from A --to B` 改用 `rtl2gds.build_flow_range(A, B)` 动态构建包含式规范范围。新范围不能与 `--preset`、`--overwrite`、`--resume`、`--only`、`--force` 组合。 +- **新建 workspace**:解析 `[design]` 输入声明、PDK、参数与请求入口步骤;只校验入口步骤所需文件;先原子登记受管名称到 `project.json`(`not_started`);预检工具;名称选择器默认在 `/` 调用 `create_workspace`,绝对路径选择器则使用该精确项目外目录。`create_workspace` 将输入复制到 `origin/` 并产出全部步骤配置,CLI 后续不改写配置。正常新建 flow 用 preset;`--from A --to B` 改用 `rtl2gds.build_flow_range(A, B)` 动态构建包含式规范范围。新范围不能与 `--preset`、`--overwrite`、`--resume`、`--only`、`--force` 组合。 - **已有 workspace**:先由 `chipcompiler/engine/reconcile.py` 把持久化 flow 与目标对齐(前缀 → 追加扩展;超集且全成 → `no_op`;分叉 → `flow_mismatch`),再 `load_workspace` 后由 `chipcompiler.engine.rerun` 的 `run_resume`、`run_from` 或 `run_only` 原地复跑。`--from A --to B` 是已有 flow 的包含式范围,会将其后的步骤状态失效但保留其输出文件。已有 workspace 不会重新预检输入,也不会改写已复制输入或配置。 `ecc workspace import NAME --path /absolute/workspace` 通过 `cli/project/workspace_registration.py` 校验持久化的 workspace 身份、flow 范围、 状态和参数差异,再把规范路径原子登记到 `project.json`。显式 -`ecc run --path` 遇到未登记的已有 workspace 时复用同一服务。登记后所有命令都 +绝对路径形式的 `ecc run --workspace /absolute/workspace` 遇到未登记的已有 workspace 时复用同一服务。登记后所有命令都 通过 manifest 按 ID 解析外部目录,不再接受第二个路径覆盖。 项目 preset 的步骤序列定义在 `chipcompiler/rtl2gds/builder.py`(`build_*_flow()` / `get_flow_builders()`),不在 CLI 层。`build_flow_range()` 对规范的 `build_rtl2gds_flow()` 结果切片,步骤别名和顺序只有一份来源。修改序列时须同步引擎默认 flow、`StepEnum` 与 manifest 范围映射;CLI 只负责参数解析、输入契约、进度渲染选择与结果映射。交互式 TTY 的 `ecc run` 走 `run_flow_with_progress()`,`--plain` 与 GUI 走 `execute()`;两条路径挂同一套 Engineering Snapshot 提交 observer,每完成一步都会更新 `home/engineering-snapshot.json`。 diff --git a/docs/development.md b/docs/development.md index ea735f40..67dc666a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -331,7 +331,7 @@ Using `ecc check --project gcd --plain` as the example: execute_command("check", command_input, project_handlers.check) ``` 3. `core/invocation.py::execute_command()` (`cli/core/invocation.py`) then: - - `build_context()`: resolves the project directory (`--project`, defaulting to cwd) → reads its sole `ecc.toml` (an unreadable file is recorded in `config_error`) → classifies the project state via `cli/project/manifest.py::classify_project()` (manifest / legacy / virgin). Manifest projects resolve `--workspace NAME` only through the `project.json` workspaces table: one active workspace auto-selects, multiple ones require the selector, and a new `ecc run --workspace NAME` target is registered before files are created. The workspace selector is a single-segment logical ID. The optional run `--path` is a complete canonical absolute target, requires an explicit ID, and is used only to create or register that ID; without it the target remains `/`. A legacy project must migrate before `ecc run`; a corrupt manifest yields `manifest_invalid`. The context derives `OutputMode` from `--plain` and carries `project_state` / `manifest_error` (`cli/core/types.py`). + - `build_context()`: resolves the project directory (`--project`, defaulting to cwd) → reads its sole `ecc.toml` (an unreadable file is recorded in `config_error`) → classifies the project state via `cli/project/manifest.py::classify_project()` (manifest / legacy / virgin). Manifest projects resolve a single-segment `--workspace NAME` through the `project.json` workspaces table, while an absolute `--workspace PATH` is canonicalized as an external target and matched by path before basename-based ID resolution. One active workspace auto-selects, multiple ones require the selector, and a new run target is registered before files are created. A legacy project must migrate before `ecc run`; a corrupt manifest yields `manifest_invalid`. The context derives `OutputMode` from `--plain`, records whether the selector was an explicit path, and carries `project_state` / `manifest_error` (`cli/core/types.py`). - Calls the handler: `handler(command_input, ctx) -> CommandResult`. - After the handler, records are appended as needed (`_with_legacy_hint` / `_with_config_shadow_hint`): `run/check/status` on a legacy project carry a migration hint (pointing at `ecc migrate`); when a workspace's `home/` holds both `params.toml` and the legacy `parameters.json`, a `workspace_config_shadowed` warning is emitted (the JSON is inert). - Renders: `rendering/renderers.py::render_command_result()` first looks up a custom renderer in `RENDERERS[(render_key, output_mode)]`, falling back to the generic `rendering/render.py::render_result()`. @@ -513,8 +513,8 @@ in `test/cli/params/`. - **Fresh workspace**: resolve `[design]` input declarations, PDK, parameters, and the requested entry step; validate only that entry step's required files; atomically pre-register the managed name in `project.json` as `not_started`; - preflight tools; call `create_workspace` at `/` or - the exact absolute `--path` when supplied. + preflight tools; call `create_workspace` at `/` for + a name selector or the exact absolute workspace path for a path selector. `create_workspace` copies inputs to `origin/` and produces all step configs; the CLI never rewrites those configs afterwards. A normal fresh flow uses a preset. `--from A --to B` instead calls `rtl2gds.build_flow_range(A, B)` to @@ -532,8 +532,9 @@ in `test/cli/params/`. `ecc workspace import NAME --path /absolute/workspace` uses `cli/project/workspace_registration.py` to validate persisted workspace identity, flow range, status, and parameter differences before atomically -adding the canonical path to `project.json`. The same service is used by an -explicit `ecc run --path` that discovers an unregistered existing workspace. +adding the canonical path to `project.json`. An absolute +`ecc run --workspace /absolute/workspace` selector uses the same service when +it discovers an unregistered existing workspace. All later commands resolve the external directory through the manifest and do not accept a second path override. diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index 24da686d..924265c5 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -282,10 +282,10 @@ The command graph follows these rules; new commands must follow them too: because `report ` reads as one action. - **Naming.** Lowercase single words; multi-word names use kebab-case (`set-root`, `layout-image`). Help strings start with an imperative verb. -- **Selectors.** `--workspace NAME` selects a declared or newly-created - managed workspace and may be combined with `--project`. `ecc run` alone may - add an absolute `--path` to place a new workspace or register an existing - one at that exact directory; omitting it keeps the project-local default. +- **Selectors.** `--workspace SELECTOR` accepts either a declared or + newly-created managed name, or a complete absolute path. A name may be + combined with `--project` and keeps the project-local default; an absolute + path creates or registers an external workspace at that exact directory. File-producing commands use `-o/--output`. - **Status vs full evidence.** `ecc status` is the lightweight progress check; `ecc report step` is the full per-step evidence report (features, analysis, @@ -355,11 +355,12 @@ step marks downstream steps `Unstart` while retaining their output files. `--only`. A new bounded workspace requires both `--from` and `--to` and cannot combine with `--preset`, `--overwrite`, `--resume`, `--only`, or `--force`. The builder dynamically slices the canonical RTL-to-GDS flow for that range. -`--workspace` is a single-segment logical ID and can be combined with -`--project`. `ecc run --workspace NAME --path /absolute/workspace` creates at -or resumes the exact path and records it in `project.json`; `--path` is not -accepted without the ID. An existing workspace can be registered without -running it using `ecc workspace import NAME --path /absolute/workspace`. +`--workspace` is either a single-segment logical ID or an absolute path and can +be combined with `--project`. `ecc run --workspace /absolute/workspace` +creates at or resumes the exact path and records it in `project.json`; the +directory basename supplies a new ID unless the path is already registered. +An existing workspace can be registered without running it using +`ecc workspace import NAME --path /absolute/workspace`. After registration all commands select it by ID, including when its directory is outside the project. Bare `ecc run` creates `default` for a project with no workspace, resumes its sole active workspace, and reports `workspace_required` diff --git a/test/cli/commands/test_manifest_discovery.py b/test/cli/commands/test_manifest_discovery.py index c5ef74dc..33804d03 100644 --- a/test/cli/commands/test_manifest_discovery.py +++ b/test/cli/commands/test_manifest_discovery.py @@ -78,7 +78,7 @@ def test_nested_workspace_name_is_invalid_not_undeclared( assert record["error"] == "invalid_workspace" assert record["reason"].startswith("invalid_workspace:") - def test_absolute_workspace_name_is_invalid_not_undeclared( + def test_absolute_workspace_path_is_not_declared( self, tmp_path, capsys, manifest_stubs ): project_dir = tmp_path / "proj" @@ -92,8 +92,8 @@ def test_absolute_workspace_name_is_invalid_not_undeclared( assert rc != 0 (record,) = manifest_stubs.records() assert record["kind"] == "error" - assert record["error"] == "invalid_workspace" - assert record["reason"].startswith("invalid_workspace:") + assert record["error"] == "workspace_not_declared" + assert "x" in record["reason"] def test_unknown_workspace_errors_with_declared_ids(self, tmp_path, capsys, manifest_stubs): project_dir = tmp_path / "proj" diff --git a/test/cli/commands/test_partial_workspace_recovery.py b/test/cli/commands/test_partial_workspace_recovery.py index 87c881c9..f9a194cf 100644 --- a/test/cli/commands/test_partial_workspace_recovery.py +++ b/test/cli/commands/test_partial_workspace_recovery.py @@ -179,5 +179,5 @@ def test_nonexistent_workspace_run_leaves_no_artifacts(self, tmp_path, capsys, p assert rc == 1 records = plain_records(capsys.readouterr().out) - assert any(r.get("error") == "invalid_workspace" for r in records) + assert any(r.get("error") == "workspace_path_unsafe" for r in records) assert not os.path.exists(os.path.join(str(tmp_path), "new")) diff --git a/test/cli/commands/test_readonly_workspace.py b/test/cli/commands/test_readonly_workspace.py index baa482df..0cea8195 100644 --- a/test/cli/commands/test_readonly_workspace.py +++ b/test/cli/commands/test_readonly_workspace.py @@ -59,15 +59,24 @@ def test_config_resolves_workspace_inside_project( class TestInvalidWorkspace: @pytest.mark.parametrize("command", (["status"], ["log"], ["config"])) - def test_workspace_path_is_not_a_name(self, tmp_path, capsys, command, plain_records): + def test_absolute_workspace_path_is_selected(self, tmp_path, capsys, command, plain_records): absent = str(tmp_path / "absent") rc = cli_main.run([*command, "--workspace", absent, "--plain"]) record = plain_records(capsys.readouterr().out)[0] - assert rc == 1 - assert record["error"] == "invalid_workspace" - assert record["reason"] == f"invalid_workspace: {absent!r} is not a single workspace name" + if command == ["status"]: + assert rc == 1 + assert record["workspace_id"] == "absent" + assert record["status"] == "missing" + assert record["workspace"] == absent + elif command == ["log"]: + assert rc == 0 + assert record["log_status"] == "no_logs" + assert record["workspace"] == absent + else: + assert rc == 1 + assert record["error"] == "missing_config" class TestWorkspaceViews: diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 27a52c66..ff660325 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -474,7 +474,7 @@ def test_invalid_workspace(self, tmp_path, capsys, plain_records): record = plain_records(capsys.readouterr().out)[0] assert rc == 1 - assert record["error"] == "invalid_workspace" + assert record["error"] == "missing_config" def test_missing_flow(self, workspace_mocks, capsys, plain_records): workspace_mocks.has_init = False diff --git a/test/cli/commands/test_workspace_import.py b/test/cli/commands/test_workspace_import.py index 82bf2dea..0e6e1f9a 100644 --- a/test/cli/commands/test_workspace_import.py +++ b/test/cli/commands/test_workspace_import.py @@ -122,6 +122,48 @@ def test_import_is_idempotent_and_external_workspace_resolves_by_id( assert any(record.get("workspace") == str(workspace.resolve()) for record in records) +def test_absolute_workspace_path_reuses_registered_id( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + workspace = tmp_path / "external" / "directory-name" + _existing_workspace(workspace) + + assert ( + cli_main.run( + [ + "workspace", + "import", + "archive", + "--project", + project_dir, + "--path", + str(workspace), + "--plain", + ] + ) + == 0 + ) + capsys.readouterr() + + assert ( + cli_main.run( + [ + "status", + "--project", + project_dir, + "--workspace", + str(workspace), + "--plain", + ] + ) + == 0 + ) + record = plain_records(capsys.readouterr().out)[0] + assert record["workspace_id"] == "archive" + assert record["workspace"] == str(workspace.resolve()) + + def test_import_rejects_invalid_workspace_without_manifest( tmp_path, capsys, create_cli_project, plain_records ): @@ -201,29 +243,22 @@ def invoke(workspace_id: str, path: Path) -> int: assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_path_conflict" -def test_explicit_workspace_path_requires_id_and_absolute_path( +def test_workspace_selector_rejects_relative_paths( tmp_path, capsys, create_cli_project, plain_records ): project_dir = create_cli_project() - - rc = cli_main.run(["run", "--project", project_dir, "--path", str(tmp_path), "--plain"]) - assert rc == 1 - assert plain_records(capsys.readouterr().out)[0]["error"] == "path_requires_workspace" - rc = cli_main.run( [ "run", "--project", project_dir, "--workspace", - "external", - "--path", "relative/workspace", "--plain", ] ) assert rc == 1 - assert plain_records(capsys.readouterr().out)[0]["error"] == "workspace_path_not_absolute" + assert plain_records(capsys.readouterr().out)[0]["error"] == "invalid_workspace" def test_run_creates_workspace_at_exact_external_path( @@ -240,8 +275,6 @@ def test_run_creates_workspace_at_exact_external_path( "--project", project_dir, "--workspace", - "created", - "--path", str(workspace), "--plain", ] @@ -253,7 +286,7 @@ def test_run_creates_workspace_at_exact_external_path( assert manifest["workspaces"][0]["workspace_path"] == str(workspace.resolve()) -def test_run_path_registers_and_resumes_existing_external_workspace( +def test_run_path_selector_registers_and_resumes_existing_external_workspace( tmp_path, capsys, create_cli_project, monkeypatch, plain_records ): project_dir = create_cli_project() @@ -293,8 +326,6 @@ def run_steps(self, **_kwargs): "--project", project_dir, "--workspace", - "resume", - "--path", str(workspace), "--resume", "--plain", @@ -309,7 +340,7 @@ def run_steps(self, **_kwargs): assert manifest["workspaces"][0]["workspace_path"] == str(workspace.resolve()) -def test_run_path_accepts_existing_empty_target(tmp_path, create_cli_project, flow_mocks): +def test_run_path_selector_accepts_existing_empty_target(tmp_path, create_cli_project, flow_mocks): project_dir = create_cli_project() workspace = tmp_path / "external" / "empty" workspace.mkdir(parents=True) @@ -320,8 +351,6 @@ def test_run_path_accepts_existing_empty_target(tmp_path, create_cli_project, fl "--project", project_dir, "--workspace", - "empty", - "--path", str(workspace), ] ) @@ -330,7 +359,7 @@ def test_run_path_accepts_existing_empty_target(tmp_path, create_cli_project, fl assert flow_mocks.capture["create_kwargs"]["directory"] == str(workspace.resolve()) -def test_run_without_path_keeps_project_local_workspace(tmp_path, create_cli_project, flow_mocks): +def test_run_workspace_name_keeps_project_local_workspace(tmp_path, create_cli_project, flow_mocks): project_dir = create_cli_project() assert cli_main.run(["run", "--project", project_dir, "--workspace", "local"]) == 0 diff --git a/test/cli/inspect/test_config.py b/test/cli/inspect/test_config.py index f7e9cffc..6fae2d1a 100644 --- a/test/cli/inspect/test_config.py +++ b/test/cli/inspect/test_config.py @@ -557,7 +557,7 @@ def test_dir_only_routing_uses_internal_step_directory_prefix( class TestAbsoluteWorkspaceSelector: - def test_absolute_workspace_selector_rejected( + def test_absolute_workspace_selector_resolves_external_path( self, tmp_path, capsys, @@ -580,10 +580,11 @@ def test_absolute_workspace_selector_rejected( project_dir, ] ) - assert rc == 1 - record = plain_records(capsys.readouterr().out)[0] - assert record["error"] == "invalid_workspace" - assert "invalid_workspace" in record["reason"] + assert rc == 0 + records = plain_records(capsys.readouterr().out) + run_dir = next(record for record in records if record.get("config") == "run_dir") + assert run_dir["value"] == str(external_run.resolve()) + assert run_dir["resolved"] == str(external_run.resolve()) class TestConfigTextUsesItemInspectCmd: diff --git a/test/cli/test_help_rendering.py b/test/cli/test_help_rendering.py index ee7a0347..66b8b044 100644 --- a/test/cli/test_help_rendering.py +++ b/test/cli/test_help_rendering.py @@ -114,6 +114,7 @@ def test_run_help_documents_fresh_run_override_rule(capsys): out = capsys.readouterr().out assert rc == 0 + assert "--path" not in out assert "set_requires_fresh_run" in out assert "cli-param-overrides.json" in out From 5118e4c7cbed2ece59bd7cb273524474b6ded269 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Wed, 16 Sep 2026 12:28:49 +0800 Subject: [PATCH 04/13] style(cli): format workspace path regression test --- test/cli/commands/test_manifest_discovery.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/cli/commands/test_manifest_discovery.py b/test/cli/commands/test_manifest_discovery.py index 33804d03..c63054d3 100644 --- a/test/cli/commands/test_manifest_discovery.py +++ b/test/cli/commands/test_manifest_discovery.py @@ -78,9 +78,7 @@ def test_nested_workspace_name_is_invalid_not_undeclared( assert record["error"] == "invalid_workspace" assert record["reason"].startswith("invalid_workspace:") - def test_absolute_workspace_path_is_not_declared( - self, tmp_path, capsys, manifest_stubs - ): + def test_absolute_workspace_path_is_not_declared(self, tmp_path, capsys, manifest_stubs): project_dir = tmp_path / "proj" project_dir.mkdir() manifest_stubs.write(project_dir, [manifest_stubs.entry(project_dir, "ws_0001")]) From e515cda8b321bcc08c132a038fe08243e63942d3 Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:52:36 +0800 Subject: [PATCH 05/13] docs: update chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index b35ab81a..a2c528c9 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -328,7 +328,7 @@ ecc run [OPTIONS] --plain 面向脚本的 key=value 输出 ``` -新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → `--workspace` 是名称时默认在 `/` 创建,是绝对路径时在该完整目录创建 → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。绝对路径必须是完整的项目外目录且父目录已存在;新路径以 basename 作为 workspace ID,已登记路径沿用清单中的 ID。外部目录中已有有效 ECC workspace 时,也可以用同一命令登记并续跑。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 +新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → `--workspace` 是名称时默认在 `/` 创建,是绝对路径时在该完整目录创建 → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。绝对路径必须是完整的项目外目录且父目录已存在;新路径以 basename 作为 workspace ID,已登记路径沿用清单中的 ID。外部目录中已有有效 ECC workspace 时,也可以用同一命令登记并续跑。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + Abstract LEF + 时序 LIB)。 #### 外部 workspace 路径 From b6a622c1cbc4be9cb157d778a4e3a2dbac3d3ded Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:52:53 +0800 Subject: [PATCH 06/13] docs: update chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index a2c528c9..8e517df1 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -494,7 +494,7 @@ $ ecc run --workspace a/b # workspace 必须是单段名称,不能是路 |---|---|---| | `run_exists` | 目标目录已存在但不是有效 ECC workspace(无 `home/flow.json`) | `--overwrite`(有安全校验)或换 `--workspace` | | `overwrite_refused` | `--overwrite` 的目标不是真正的 ECC workspace 目录 | 人工确认目录内容后手动清理 | -| `invalid_workspace` | workspace 名含 `/`、是相对路径或 `.`/`..`;或目录不是可加载的 workspace | 使用单段名称或完整绝对路径 / 检查目录 | +| `invalid_workspace` | workspace 名含 `/`、是相对路径或 `.`/`..`;或目录不是可加载的 workspace | 使用简单名称(如 myproject)或完整绝对路径 / 检查目录(如 /home/user/myproject) | | `workspace_required` | 项目有多个活跃 workspace 但没传 `--workspace` | 按报错列出的名称指定其一 | | `workspace_not_declared` | `--workspace` 名与 `project.json` 声明的 id 不一致(含别名指向已声明路径) | 使用报错中给出的已声明 id | | `workspace_path_not_absolute` | 导入路径不是绝对路径 | 为 `workspace import` 传入绝对 workspace 目录 | From 78f561d974b4ea5bb0a30412519bb8a369a279a8 Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:53:25 +0800 Subject: [PATCH 07/13] docs: pdate chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 8e517df1..7508742e 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -80,7 +80,7 @@ uv run ecc --help ## 1. 通用约定 - 全局:`ecc --version`(单行版本号)、`ecc --help`。 -- 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <选择器>` 可以是受管的非空单段名称,也可以是完整绝对路径。名称继续使用项目内的 `/` 布局;绝对路径创建或选择项目外 workspace,新路径默认以目录 basename 作为 workspace ID,已登记路径沿用清单中的 ID。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 +- 项目定位:项目级命令接受 `--project `(缺少参数指定即为当前目录)。`--workspace <路径指定>` 可以是受工具管理的非空简单文件夹路径,也可以是完整绝对路径。名称继续使用项目内的 `/` 布局;绝对路径创建或选择项目外 workspace,新路径默认以目录 basename 作为 workspace ID,已登记路径沿用清单中的 ID。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 - 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`rpc serve` 和 `layout-image` 使用各自的协议。 - 退出码:成功 0;业务失败 1(错误记录形如 `[error] error=<机器可读错误码>`)。 - 步骤名(step token)有三套写法,按场景区分: From a58681d19becca92bb3e292d370d00bfc2b16aa2 Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:53:43 +0800 Subject: [PATCH 08/13] docs: pdate chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 7508742e..a787459d 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -332,7 +332,7 @@ ecc run [OPTIONS] #### 外部 workspace 路径 -当 workspace 必须放在项目目录外时,把完整绝对目录直接作为 `--workspace` 选择器。它表示 workspace 的完整目录,不是父目录: +当 workspace 必须放在项目目录外时,把完整绝对目录直接作为 `--workspace` 的路径参数。它表示 workspace 的完整目录,不是父目录: ```bash # 原有行为:在项目下创建并登记 workspace。 From 2637022bbc82bd9158a35ec5d2716889f1b55686 Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:53:51 +0800 Subject: [PATCH 09/13] docs: update chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index a787459d..3e255fec 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -336,7 +336,7 @@ ecc run [OPTIONS] ```bash # 原有行为:在项目下创建并登记 workspace。 -ecc run --project /projects/gcd --workspace local +ecc run --project /projects/gcd --workspace # 在项目外创建并运行受管 workspace。 ecc run --project /projects/gcd \ From 8d51fc94ba4c7f38c2677c89319a4f4f1e089009 Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:53:59 +0800 Subject: [PATCH 10/13] docs: update chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 3e255fec..bc38b502 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -338,7 +338,7 @@ ecc run [OPTIONS] # 原有行为:在项目下创建并登记 workspace。 ecc run --project /projects/gcd --workspace -# 在项目外创建并运行受管 workspace。 +# 在项目外创建并运行受工具管理的 workspace。 ecc run --project /projects/gcd \ --workspace /data/ecc-runs/gcd/archive From acaf691e112a91de830660a627965f6f859dde66 Mon Sep 17 00:00:00 2001 From: Yell <35290141+Yell-walkalone@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:54:10 +0800 Subject: [PATCH 11/13] docs: update chipcompiler/docs/ecc-user-guide.cn.md Co-authored-by: Qiming Chu --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index bc38b502..ff971219 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -347,7 +347,7 @@ ecc run --project /projects/gcd --workspace archive --resume ecc status --project /projects/gcd --workspace archive ``` -路径必须是绝对路径。`ecc` 只创建最后一级目录,因此父目录必须已存在;已有非空目录必须已经是有效 ECC workspace。项目根目录和 legacy `runs/` 目录是受保护目标,包含项目目录的路径也会被拒绝。同一个 workspace ID 不能重新绑定到另一个路径,已登记给其他 ID 的路径也不能重复使用。要登记已有 workspace 但不执行或修改它,请使用 `ecc workspace import`。 +路径必须是绝对路径。`ecc` 只创建最后一级目录,因此父目录必须已存在;已有非空目录必须已经是有效 ECC workspace。项目根目录和 legacy `runs/` 目录是受项目目录,包含项目目录的路径也会被拒绝。同一个 workspace ID 不能重新绑定到另一个路径,已登记给其他 ID 的路径也不能重复使用。要登记已有 workspace 但不执行或修改它,请使用 `ecc workspace import`。 `synthesis_lec` preset 需要默认策略跳过的 LEC,因此本示例的项目先编辑 `ecc.toml`(`sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml` 或手动修改)显式设置 `skip_steps = []`: From 3e0a107e37739a6619b930effa281cbc33483141 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 16 Sep 2026 16:25:52 +0800 Subject: [PATCH 12/13] docs: update chipcompiler/docs/ecc-user-guide.en.md --- chipcompiler/docs/ecc-user-guide.en.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 79963972..16027c82 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -80,7 +80,7 @@ uv run ecc --help ## 1. General conventions - Global: `ecc --version` (single version line), `ecc --help`. -- Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` accepts either a managed, non-empty single-segment name or a complete absolute filesystem path. A name keeps the project-local `/` layout; an absolute path creates or selects an external workspace and uses its basename as the new ID unless the path is already registered. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. +- Project location: project-scoped commands accept `--project ` (when the option is not given, the current directory is used). `--workspace ` accepts either a tool-managed, non-empty simple folder name or a complete absolute filesystem path. A name keeps the project-local `/` layout; an absolute path creates or selects an external workspace and uses its basename as the new ID unless the path is already registered. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. - Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `rpc serve` and `layout-image` use their own protocols instead. - Exit codes: 0 on success; 1 on business failure (error records look like `[error] error=`). - Step tokens come in three vocabularies, distinguished by context: @@ -329,17 +329,17 @@ ecc run [OPTIONS] --plain key=value output for scripting ``` -For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` when the selector is a name, or at the exact absolute path when the selector is a path → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. An absolute workspace selector must be a complete external directory whose parent already exists; its basename becomes the new workspace ID unless the path is already registered. An existing valid workspace at an external path can be registered and resumed with the same command. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). +For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` when the selector is a name, or at the exact absolute path when the selector is a path → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. An absolute workspace selector must be a complete external directory whose parent already exists; its basename becomes the new workspace ID unless the path is already registered. An existing valid workspace at an external path can be registered and resumed with the same command. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + Abstract LEF + timing LIB). #### External workspace paths -Pass an absolute path directly to `--workspace` when the workspace directory must live outside the project. The selector names the complete workspace directory, not a parent directory: +Pass the complete absolute directory directly as the `--workspace` path argument when the workspace directory must live outside the project. It refers to the complete workspace directory, not a parent directory: ```bash # Existing behavior: create and register the workspace below the project. -ecc run --project /projects/gcd --workspace local +ecc run --project /projects/gcd --workspace -# Create and run a managed workspace outside the project. +# Create and run a tool-managed workspace outside the project. ecc run --project /projects/gcd \ --workspace /data/ecc-runs/gcd/archive @@ -497,7 +497,7 @@ $ ecc run --workspace a/b # a workspace must be a single name, never a path |---|---|---| | `run_exists` | the target directory already exists but is not a valid ECC workspace (no `home/flow.json`) | `--overwrite` (with safety checks) or a different `--workspace` | | `overwrite_refused` | the `--overwrite` target is not a genuine ECC workspace directory | inspect the directory contents and clean it up manually | -| `invalid_workspace` | the workspace name contains `/`, is a relative path, or is `.`/`..`; or the directory is not a loadable workspace | use a single-segment name or complete absolute path / inspect the directory | +| `invalid_workspace` | the workspace name contains `/`, is a relative path, or is `.`/`..`; or the directory is not a loadable workspace | use a simple name (e.g. `myproject`) or a complete absolute path / inspect the directory (e.g. `/home/user/myproject`) | | `workspace_required` | the project has multiple active workspaces but no `--workspace` was given | pass one of the names listed in the error | | `workspace_not_declared` | the `--workspace` name does not match an id declared in `project.json` (including aliases pointing at a declared path) | use the declared id given in the error | | `workspace_path_not_absolute` | the import path is not absolute | pass an absolute workspace directory to `workspace import` | From 0ff9af9560fa4adad54ee3b6fdc58efd65173a7e Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 16 Sep 2026 16:26:43 +0800 Subject: [PATCH 13/13] docs: update chipcompiler/docs/ecc-user-guide.cn.md --- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index ff971219..090ec30c 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -347,7 +347,7 @@ ecc run --project /projects/gcd --workspace archive --resume ecc status --project /projects/gcd --workspace archive ``` -路径必须是绝对路径。`ecc` 只创建最后一级目录,因此父目录必须已存在;已有非空目录必须已经是有效 ECC workspace。项目根目录和 legacy `runs/` 目录是受项目目录,包含项目目录的路径也会被拒绝。同一个 workspace ID 不能重新绑定到另一个路径,已登记给其他 ID 的路径也不能重复使用。要登记已有 workspace 但不执行或修改它,请使用 `ecc workspace import`。 +路径必须是绝对路径。`ecc` 只创建最后一级目录,因此父目录必须已存在;已有非空目录必须已经是有效 ECC workspace。项目根目录和 legacy `runs/` 目录是受项目保护目录,包含项目目录的路径也会被拒绝。同一个 workspace ID 不能重新绑定到另一个路径,已登记给其他 ID 的路径也不能重复使用。要登记已有 workspace 但不执行或修改它,请使用 `ecc workspace import`。 `synthesis_lec` preset 需要默认策略跳过的 LEC,因此本示例的项目先编辑 `ecc.toml`(`sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml` 或手动修改)显式设置 `skip_steps = []`: