From b4ef73caad5ee304f0efd53fb97812da08d1678e Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Thu, 23 Jul 2026 12:24:59 -0600 Subject: [PATCH] feat(tui): Paramify tab redesign, focus/shortcut fixes, manifest-scoped scripts sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collection of TUI improvements for the next release, plus the behavior change they surfaced: - Paramify tab: rebuilt into stacked evidence-upload + scripts-sync panels. A Preview action runs a read-only dry-run and surfaces the per-fetcher plan (create/update/drift/noop) in a table, flagging what --force would push. - Scripts sync is now manifest-scoped by default (provisions scripts only for the fetchers you collect, like `upload`); `--all` pushes the whole catalog. Threaded an `include` filter through the uploader, api, CLI (new manifest arg + --all), and TUI (active manifest). - Focus/keyboard fix: page shortcuts (ctrl+r/ctrl+u/ctrl+s) fire regardless of focus; default focus lands in the active pane on mount, tab switch (mouse too), and escape — not just on number-key switches. - Add-fetchers picker: a fully-added category (e.g. datadog) no longer disappears; already-added fetchers render greyed-out and non-selectable. - Uniform sizing for the Paramify action row (Preview/Sync/force/reassociate) — equal width, and checkboxes flattened to one row to match the buttons. Tests: 233 passed (2 new scoping tests); ruff + mypy clean. TUI verified with headless Textual Pilot runs (focus, plan population, sizing). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 29 ++- framework/api.py | 17 +- framework/cli.py | 18 +- framework/tui/modals.py | 25 +- framework/tui/screens/manifest.py | 10 +- framework/tui/screens/upload.py | 307 ++++++++++++++++--------- framework/tui/screens/workspace.py | 41 +++- framework/tui/styles/index.tcss | 56 ++--- tests/test_scripts_uploader.py | 38 ++- uploaders/paramify_scripts/uploader.py | 25 +- 10 files changed, 406 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a20aef..c207854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,14 +17,35 @@ schemas and the `paramify` CLI — not the internal code. tenant records *how* each piece of evidence is generated. A provisioning step separate from `paramify upload`: it reconciles the tenant to the repo GitOps-style (marker-keyed identity in the script description, `fetcher.yaml` `version` as the - update signal, a sha256 drift guard), with `--dry-run` / `--force` / - `--reassociate` / `--json`. Backed by the new `uploaders/paramify_scripts/` - uploader and surfaced in the TUI's Paramify tab. Only `SCRIPT` associations are - automated; control / solution-capability / validator linkage stays Paramify-side. + update signal, a sha256 drift guard). **Scoped to a manifest by default** — it + provisions scripts only for the fetchers you collect, mirroring how `upload` is + run-scoped — with `--all` to push the whole catalog, plus `--dry-run` / `--force` / + `--reassociate` / `--json`. Backed by the `uploaders/paramify_scripts/` uploader. + Only `SCRIPT` associations are automated; control / solution-capability / validator + linkage stays Paramify-side. - [`docs/uploader_design.md`](docs/uploader_design.md) — a dedicated uploader design doc covering both built uploaders and the shared evidence-set identity model, and a README section + docs-table entries pointing to it. +### Changed + +- **TUI Paramify tab redesigned** into stacked *evidence upload* and *scripts sync* + panels. Scripts sync gained a **Preview** action that runs a read-only dry-run and + surfaces the per-fetcher plan (create / update / drift / noop) in a table — flagging + which drifted scripts `--force` would push — and syncs the active manifest's fetchers. + +### Fixed + +- TUI: page keyboard shortcuts (`ctrl+r` / `ctrl+u` / `ctrl+s`) now fire regardless of + which control is focused, and default focus lands in the active pane on mount, on tab + switches (mouse clicks included), and after `escape` — previously they worked only + right after a number-key tab switch. +- TUI: the *Add fetchers* picker no longer drops a category once all its fetchers are in + the manifest; already-added fetchers show greyed-out and non-selectable, so a + fully-added category (e.g. `datadog`) stays visible. +- TUI: the Paramify action row (Preview / Sync Scripts / force / reassociate) now uses + uniform control sizes instead of content-sized widths and mismatched heights. + ## [0.2.1-beta] - 2026-07-10 ### Changed diff --git a/framework/api.py b/framework/api.py index 24812fd..8bb71f7 100644 --- a/framework/api.py +++ b/framework/api.py @@ -782,8 +782,13 @@ def scripts_sync_preflight( config_path: Optional[Path] = None, *, dry_run: bool = False, + include: Optional[set] = None, ) -> dict: - """Inspect scripts-sync readiness without making Paramify API calls.""" + """Inspect scripts-sync readiness without making Paramify API calls. + + ``include`` restricts the fetcher count to those names (a manifest's + fetchers); ``None`` counts every discovered fetcher. + """ uploader = _load_paramify_scripts_uploader(root) uploader.load_dotenv() config = uploader.load_config(str(config_path)) if config_path else {} @@ -797,10 +802,13 @@ def scripts_sync_preflight( errors: List[str] = [] fetcher_count = 0 for f in discover_fetchers(root).values(): + if include is not None and f.name not in include: + continue if f.evidence_set and f.entry_path.exists(): fetcher_count += 1 if fetcher_count == 0: - errors.append("No fetchers with an evidence_set and a readable entry file to sync") + scope = "in the manifest " if include is not None else "" + errors.append(f"No fetchers {scope}with an evidence_set and a readable entry file to sync") url_error = uploader._base_url_error(base_url) if url_error: @@ -829,12 +837,16 @@ def scripts_sync( dry_run: bool = False, force: bool = False, reassociate: bool = False, + include: Optional[set] = None, on_event: Optional[Callable[[dict], None]] = None, ) -> dict: """Sync fetcher entry scripts to Paramify and associate them to evidence sets. Fires sync_start / sync_item / sync_complete so front-ends can render progress. Raises ValueError for setup errors; returns the uploader summary. + + ``include`` restricts the sweep to those fetcher names (a manifest's + fetchers); ``None`` syncs every discovered fetcher. """ uploader = _load_paramify_scripts_uploader(root) config = uploader.load_config(str(config_path)) if config_path else {} @@ -844,6 +856,7 @@ def scripts_sync( dry_run=dry_run, force=force, reassociate=reassociate, + include=include, on_event=on_event, ) diff --git a/framework/cli.py b/framework/cli.py index 766a98a..400d8d5 100644 --- a/framework/cli.py +++ b/framework/cli.py @@ -633,17 +633,30 @@ def on_event(ev: dict) -> None: @scripts_app.command("sync") def scripts_sync_cmd( + manifest: Optional[str] = typer.Argument(None, help=f"Sync only the fetchers this manifest uses (default: {_DEFAULT_MANIFEST}). Ignored with --all."), config: Optional[str] = typer.Option(None, "--config", help="Uploader config YAML (base_url, overrides)"), dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan; read-only (no writes)"), force: bool = typer.Option(False, "--force", help="Push scripts whose code drifted without a version bump"), reassociate: bool = typer.Option(False, "--reassociate", help="Ensure the association for every fetcher, not just changed ones"), + all_fetchers: bool = typer.Option(False, "--all", help="Sync every fetcher in the repo, not just the manifest's"), json_out: bool = typer.Option(False, "--json", help="Emit JSON summary"), ): - """Sync fetcher entry scripts to Paramify and associate them to evidence sets.""" + """Sync fetcher entry scripts to Paramify and associate them to evidence sets. + + Scoped to a manifest by default — it provisions scripts for the fetchers you + actually collect, mirroring how `upload` is run-scoped. Use --all to push the + whole catalog. + """ root = api.find_repo_root() config_path = Path(config).resolve() if config else None + include = None + if not all_fetchers: + mpath = Path(manifest).resolve() if manifest else (root / _DEFAULT_MANIFEST) + m = api.read_manifest(mpath) + entries = (m.get("run") or {}).get("fetchers") or [] + include = {e.get("use") for e in entries if e.get("use")} try: - preflight = api.scripts_sync_preflight(root, config_path, dry_run=dry_run) + preflight = api.scripts_sync_preflight(root, config_path, dry_run=dry_run, include=include) except Exception as e: # noqa: BLE001 — surface setup errors to CLI users if json_out: typer.echo(json.dumps({"ok": False, "errors": [str(e)]}, indent=2)) @@ -665,6 +678,7 @@ def scripts_sync_cmd( dry_run=dry_run, force=force, reassociate=reassociate, + include=include, on_event=None if json_out else _human_scripts_printer(), ) except Exception as e: # noqa: BLE001 diff --git a/framework/tui/modals.py b/framework/tui/modals.py index ee37fe7..0737142 100644 --- a/framework/tui/modals.py +++ b/framework/tui/modals.py @@ -164,14 +164,18 @@ class MultiPickerModal(ModalScreen[list]): ] def __init__( - self, title: str, groups: List[Tuple[str, List[str]]], subtitle: str = "" + self, title: str, groups: List[Tuple[str, List[str]]], subtitle: str = "", + disabled: set[str] | None = None, ) -> None: - # groups: ordered [(platform, [fetcher_name, ...]), ...] — categories as - # the catalog sorts them; only non-already-added fetchers. + # groups: ordered [(platform, [fetcher_name, ...]), ...] — every discovered + # fetcher, in the catalog's order. `disabled` names are already in the + # manifest: shown for context (so a fully-added category still appears) + # but not selectable. super().__init__() self._title = title self._subtitle = subtitle self._groups = groups + self._disabled = set(disabled or ()) self._cat_of = {name: cat for cat, names in groups for name in names} self._all_ids = [name for _, names in groups for name in names] self._chosen: set = set() @@ -203,6 +207,13 @@ def on_mount(self) -> None: def _leaf_label(self, name: str) -> Text: # A Rich Text (not a markup string) — "[x]" would otherwise be parsed as # a console-markup tag and vanish. Lets us tint the checked marker too. + if name in self._disabled: + # Already in the manifest: shown for context, not selectable. + label = Text() + label.append("✓ ", style="green") + label.append(name, style="dim") + label.append(" in manifest", style="dim italic") + return label checked = name in self._chosen label = Text() label.append("[x] " if checked else "[ ] ", style="green" if checked else "dim") @@ -217,9 +228,13 @@ def _populate(self, flt: str) -> None: matches = [n for n in names if not flt or flt in n.lower()] if not matches: continue + # Count what's actually addable; a fully-added platform still shows + # (labelled "all added") so the category never silently disappears. + addable = sum(1 for n in matches if n not in self._disabled) + count = f"({addable})" if addable else "(all added)" # Filtering opens the platforms with hits; otherwise stay collapsed # so a long catalog reads as a tidy list of platforms to open. - node = tree.root.add(f"{cat} ({len(matches)})", expand=bool(flt)) + node = tree.root.add(f"{cat} {count}", expand=bool(flt)) for name in matches: node.add_leaf(self._leaf_label(name), data=name) @@ -234,6 +249,8 @@ def _on_select(self, event: Tree.NodeSelected) -> None: if name is None: # a platform row → open/close the dropdown node.toggle() return + if name in self._disabled: + return # already in the manifest — shown for context, not selectable if name in self._chosen: self._chosen.discard(name) else: diff --git a/framework/tui/screens/manifest.py b/framework/tui/screens/manifest.py index dad8322..dffe1c5 100644 --- a/framework/tui/screens/manifest.py +++ b/framework/tui/screens/manifest.py @@ -288,11 +288,14 @@ def action_add_fetcher(self) -> None: groups = [] if cat: for c in cat["categories"]: - names = [f["name"] for f in c["fetchers"] if f["name"] not in existing] + # Pass every fetcher (not just addable ones): the picker shows the + # already-added ones greyed out so a fully-added category — e.g. + # datadog once all 13 are in — still appears instead of vanishing. + names = [f["name"] for f in c["fetchers"]] if names: groups.append((c["name"], names)) if not groups: - self.notify("Every discovered fetcher is already in the manifest.") + self.notify("No fetchers discovered.") return def done(names: Optional[List[str]]) -> None: @@ -325,7 +328,8 @@ def done(names: Optional[List[str]]) -> None: MultiPickerModal( "Add fetchers", groups, - subtitle="enter/space opens a platform or toggles a fetcher · type to filter", + subtitle="enter/space opens a platform or toggles a fetcher · ✓ = already in manifest · type to filter", + disabled=existing, ), done, ) diff --git a/framework/tui/screens/upload.py b/framework/tui/screens/upload.py index 586d6eb..591d63e 100644 --- a/framework/tui/screens/upload.py +++ b/framework/tui/screens/upload.py @@ -1,12 +1,15 @@ """Paramify page — push to Paramify. -Two write actions share this screen because they share all their plumbing -(token, base_url, overrides config, the event-stream shape): +Two write actions share this page because they share all their plumbing (token, +base_url, overrides config, the event-stream shape), stacked as two panels: - * Upload — attach a completed run's evidence to its evidence sets (run-scoped; - follows Evidence in the tab flow). - * Sync Scripts — push each fetcher's entry script and associate it to its + * Evidence upload — attach a completed run's evidence to its evidence sets + (run-scoped; follows Evidence in the tab flow). + * Scripts sync — push each fetcher's entry script and associate it to its evidence set (repo-scoped provisioning; independent of the selected run). + Preview runs a read-only --dry-run and surfaces the per-fetcher plan + (create / update / drift / noop) so you can see what a real sync would do — + including which drifted scripts only --force would push. """ from __future__ import annotations @@ -41,34 +44,37 @@ def __init__(self, ev: dict) -> None: class UploadPage(Vertical): - HINTS = [("ctrl+r", "refresh"), ("ctrl+u", "upload"), ("ctrl+s", "sync scripts")] + HINTS = [("ctrl+u", "upload"), ("ctrl+p", "preview"), ("ctrl+s", "sync"), ("ctrl+r", "refresh")] BINDINGS = [ - Binding("ctrl+r", "refresh_upload", "Refresh"), Binding("ctrl+u", "upload_run", "Upload"), + Binding("ctrl+p", "preview_scripts", "Preview"), Binding("ctrl+s", "sync_scripts", "Sync Scripts"), + Binding("ctrl+r", "refresh_upload", "Refresh"), ] def compose(self) -> ComposeResult: - with Horizontal(id="upload-top"): - yield Button("Refresh", id="upload-refresh") - yield Button("Upload to Paramify", variant="primary", id="upload-submit", disabled=True) - yield Button("Sync Scripts", variant="primary", id="scripts-submit", disabled=True) - yield Static("", id="upload-banner") - with Horizontal(id="upload-options"): - yield Static("scripts sync:", classes="options-label") - yield Checkbox("dry-run", id="scripts-dry") - yield Checkbox("force", id="scripts-force") - yield Checkbox("reassociate", id="scripts-reassociate") - with Horizontal(id="upload-body"): - with Vertical(id="upload-summary-panel", classes="panel"): - yield DataTable(id="upload-summary") - with Vertical(id="upload-log-panel", classes="panel"): - yield RichLog(id="upload-log", markup=False, wrap=True, highlight=False) - yield Static( - f"upload progress streams here — [bold {palette.ACCENT}]ctrl+u[/] to upload", - classes="empty-hint", - ) + with Vertical(id="evidence-panel", classes="panel"): + yield DataTable(id="evidence-summary") + with Horizontal(id="evidence-actions"): + yield Button("Upload to Paramify", variant="primary", id="upload-submit", disabled=True) + with Vertical(id="scripts-panel", classes="panel"): + yield Static("", id="scripts-header") + yield Static("", id="scripts-plan-summary") + yield DataTable(id="scripts-plan") + with Horizontal(id="scripts-actions"): + yield Button("Preview", variant="primary", id="scripts-preview", disabled=True) + yield Button("Sync Scripts", variant="primary", id="scripts-submit", disabled=True) + yield Checkbox("force", id="scripts-force") + yield Checkbox("reassociate", id="scripts-reassociate") + with Vertical(id="upload-log-panel", classes="panel"): + yield RichLog(id="upload-log", markup=False, wrap=True, highlight=False) + yield Static( + f"progress streams here — [bold {palette.ACCENT}]ctrl+u[/] upload · " + f"[bold {palette.ACCENT}]ctrl+p[/] preview · [bold {palette.ACCENT}]ctrl+s[/] sync", + classes="empty-hint", + ) + yield Static("", id="upload-banner") def on_mount(self) -> None: self._uploading = False @@ -76,23 +82,31 @@ def on_mount(self) -> None: self._run_dir: str | None = None self._preflight: dict | None = None self._scripts_preflight: dict | None = None - self.query_one("#upload-summary-panel", Vertical).border_title = "ready to upload" + self._plan_counts: dict[str, int] = {} + + self.query_one("#evidence-panel", Vertical).border_title = "evidence upload" + self.query_one("#scripts-panel", Vertical).border_title = "scripts sync" log_panel = self.query_one("#upload-log-panel", Vertical) log_panel.border_title = "log" log_panel.set_class(True, "empty") - table = self.query_one("#upload-summary", DataTable) - table.cursor_type = "row" - table.zebra_stripes = True - table.add_columns("field", "value") + + ev = self.query_one("#evidence-summary", DataTable) + ev.cursor_type = "row" + ev.zebra_stripes = True + ev.add_columns("field", "value") + + plan = self.query_one("#scripts-plan", DataTable) + plan.cursor_type = "row" + plan.zebra_stripes = True + plan.add_columns("fetcher", "action") + self.rebuild() def focus_default(self) -> None: self.rebuild() - button = self.query_one("#upload-submit", Button) - if not button.disabled: - button.focus() - else: - self.query_one("#upload-refresh", Button).focus() + submit = self.query_one("#upload-submit", Button) + target = submit if not submit.disabled else self.query_one("#scripts-preview", Button) + target.focus() @property def _busy(self) -> bool: @@ -104,30 +118,37 @@ def _output_dir(self) -> str: run = (getattr(self.app, "manifest", None) or {}).get("run") or {} return run.get("output_dir") or "./evidence" + def _manifest_fetcher_names(self) -> set: + """The fetchers the active manifest uses — scripts sync is scoped to these + (you provision scripts for the evidence you actually collect), not the + whole repo catalog.""" + manifest = getattr(self.app, "manifest", None) or {} + entries = (manifest.get("run") or {}).get("fetchers") or [] + return {e.get("use") for e in entries if e.get("use")} + def rebuild(self) -> None: + """Refresh readiness for both panels (cheap; no network). The scripts + plan itself is populated on demand by Preview / Sync, not here.""" if self._busy: return - table = self.query_one("#upload-summary", DataTable) - table.clear() - self._rebuild_evidence(table) - table.add_row("", "") - self._rebuild_scripts(table) + self._rebuild_evidence() + self._rebuild_scripts() - def _rebuild_evidence(self, table: DataTable) -> None: + def _rebuild_evidence(self) -> None: """Evidence-upload readiness (run-scoped). Sets self._run_dir/_preflight - and the upload button; never returns early from rebuild().""" + and the upload button.""" self._run_dir = None self._preflight = None + table = self.query_one("#evidence-summary", DataTable) + table.clear() upload = self.query_one("#upload-submit", Button) upload.disabled = True out = self._output_dir() - table.add_row("EVIDENCE", "attach a run's files to its evidence sets") table.add_row("output dir", out) try: runs = api.list_runs(out) except Exception as exc: - self._set_banner(Text(f"cannot list runs: {exc}", style=palette.FAIL)) table.add_row("status", Text(f"cannot list runs: {exc}", style=palette.FAIL)) return if not runs: @@ -142,7 +163,6 @@ def _rebuild_evidence(self, table: DataTable) -> None: try: preflight = api.upload_preflight(self._run_dir, self.app.root_path) except Exception as exc: - self._set_banner(Text(f"upload setup failed: {exc}", style=palette.FAIL)) table.add_row("preflight", Text(str(exc), style=palette.FAIL)) return @@ -152,34 +172,46 @@ def _rebuild_evidence(self, table: DataTable) -> None: table.add_row("upload files", str(preflight["file_count"])) if preflight["ok"]: upload.disabled = False - self._set_banner(Text("ready — upload evidence, or sync scripts", style=palette.OK)) else: for err in preflight["errors"]: table.add_row("preflight error", Text(err, style=palette.FAIL)) - self._set_banner(Text("upload preflight failed", style=palette.FAIL)) - def _rebuild_scripts(self, table: DataTable) -> None: - """Scripts-sync readiness (repo-scoped). Enabled whenever there are - fetchers to sync — independent of any run selection.""" + def _rebuild_scripts(self) -> None: + """Scripts-sync readiness (repo-scoped). Preview/Sync enabled whenever + there are fetchers to sync — independent of any run selection.""" self._scripts_preflight = None + preview = self.query_one("#scripts-preview", Button) sync = self.query_one("#scripts-submit", Button) + preview.disabled = True sync.disabled = True + header = self.query_one("#scripts-header", Static) - table.add_row("SCRIPTS", "push fetcher entry scripts + associate to sets") try: - pf = api.scripts_sync_preflight(self.app.root_path, dry_run=False) + pf = api.scripts_sync_preflight( + self.app.root_path, dry_run=True, include=self._manifest_fetcher_names() + ) except Exception as exc: - self._set_banner(Text(f"scripts preflight failed: {exc}", style=palette.FAIL)) - table.add_row("scripts preflight", Text(str(exc), style=palette.FAIL)) + header.update(Text(f"scripts preflight failed: {exc}", style=palette.FAIL)) return self._scripts_preflight = pf - table.add_row("fetchers", str(pf["fetcher_count"])) - table.add_row("API token", palette.pill("present", "ok") if pf["token_present"] else palette.pill("missing (dry-run ok)", "warn")) - table.add_row("Paramify API", pf["base_url"]) - # Enabled if there's anything to sync; a real (non-dry-run) sync still - # checks for the token at click time. - sync.disabled = pf["fetcher_count"] == 0 + token = ( + palette.pill("token present", "ok") if pf["token_present"] + else palette.pill("token missing — preview only", "warn") + ) + hdr = Text(f"{pf['fetcher_count']} fetchers in manifest → {pf['base_url']} ") + hdr.append_text(token) + header.update(hdr) + + enabled = pf["fetcher_count"] > 0 + preview.disabled = not enabled + sync.disabled = not enabled + + # Prompt only while no plan has been computed yet this session. + if self.query_one("#scripts-plan", DataTable).row_count == 0: + self.query_one("#scripts-plan-summary", Static).update( + Text("Preview (ctrl+p) computes the plan — which scripts create / update / drift", style="dim") + ) @staticmethod def _result_text(run: dict) -> Text: @@ -194,10 +226,6 @@ def _result_text(run: dict) -> Text: # -- actions: evidence upload ---------------------------------------- # - @on(Button.Pressed, "#upload-refresh") - def _on_refresh(self) -> None: - self.action_refresh_upload() - @on(Button.Pressed, "#upload-submit") def _on_upload(self) -> None: self.action_upload_run() @@ -242,10 +270,26 @@ def _upload_worker(self, run_dir: str, root) -> None: # -- actions: scripts sync ------------------------------------------- # + @on(Button.Pressed, "#scripts-preview") + def _on_preview(self) -> None: + self.action_preview_scripts() + @on(Button.Pressed, "#scripts-submit") def _on_sync(self) -> None: self.action_sync_scripts() + def action_preview_scripts(self) -> None: + """Read-only dry-run: compute and surface the plan. No token required, + no confirmation (it makes no writes).""" + if self._busy: + self.notify("A Paramify operation is already in progress.") + return + pf = self._scripts_preflight + if not pf or pf.get("fetcher_count", 0) == 0: + self.notify("No fetcher scripts to plan.") + return + self._start_scripts(dry_run=True) + def action_sync_scripts(self) -> None: if self._busy: self.notify("A Paramify operation is already in progress.") @@ -254,54 +298,56 @@ def action_sync_scripts(self) -> None: if not pf or pf.get("fetcher_count", 0) == 0: self.notify("No fetcher scripts to sync.") return - dry = self.query_one("#scripts-dry", Checkbox).value - if not dry and not pf.get("token_present"): - self.notify("API token missing — check dry-run or set PARAMIFY_UPLOAD_API_TOKEN.") + if not pf.get("token_present"): + self.notify("API token missing — set PARAMIFY_UPLOAD_API_TOKEN (Preview still works).") return - if dry: - self._start_scripts() # read-only: no confirmation needed - return + force = self.query_one("#scripts-force", Checkbox).value + extra = " (force: push drifted scripts)" if force else "" def go(ok: bool) -> None: if ok: - self._start_scripts() + self._start_scripts(dry_run=False) self.app.push_screen( ConfirmModal( f"Sync {pf['fetcher_count']} fetcher script(s) to {pf['base_url']} " - "and associate them to their evidence sets?" + f"and associate them to their evidence sets?{extra}" ), go, ) - def _start_scripts(self) -> None: + def _start_scripts(self, *, dry_run: bool) -> None: self._syncing = True self._disable_actions() self.query_one("#upload-log-panel", Vertical).set_class(False, "empty") self.query_one("#upload-log", RichLog).clear() - self._set_banner(Text("syncing scripts to Paramify...", style=palette.WARN)) + self._reset_plan() + verb = "previewing" if dry_run else "syncing" + self._set_banner(Text(f"{verb} scripts...", style=palette.WARN)) self._scripts_worker( self.app.root_path, - dry_run=self.query_one("#scripts-dry", Checkbox).value, + dry_run=dry_run, force=self.query_one("#scripts-force", Checkbox).value, reassociate=self.query_one("#scripts-reassociate", Checkbox).value, + include=self._manifest_fetcher_names(), ) @work(thread=True, exclusive=True) - def _scripts_worker(self, root, dry_run: bool, force: bool, reassociate: bool) -> None: + def _scripts_worker(self, root, dry_run: bool, force: bool, reassociate: bool, include: set) -> None: try: api.scripts_sync( root, dry_run=dry_run, force=force, reassociate=reassociate, + include=include, on_event=lambda ev: self.post_message(ScriptsSyncEvent(ev)), ) except Exception as exc: self.post_message(ScriptsSyncEvent({"event": "_scripts_failed", "error": str(exc)})) - # -- events ----------------------------------------------------------- # + # -- events: evidence upload ----------------------------------------- # def on_upload_event(self, message: UploadEvent) -> None: self._handle_upload_event(message.ev) @@ -350,31 +396,42 @@ def _finalize_upload(self, ev: dict) -> None: msg.append(f" {ev['log_path']}", style="dim") self._set_banner(msg) + # -- events: scripts sync -------------------------------------------- # + def on_scripts_sync_event(self, message: ScriptsSyncEvent) -> None: self._handle_scripts_event(message.ev) - _SCRIPT_MARKS = { - "create": ("NEW", palette.OK), "update": ("UPD", palette.OK), "noop": ("OK", "dim"), - "drift": ("DRIFT", palette.WARN), "drift_skipped": ("DRIFT", palette.WARN), "error": ("FAIL", palette.FAIL), - "would_create": ("NEW?", palette.INFO), "would_update": ("UPD?", palette.INFO), - "would_noop": ("OK?", "dim"), "would_drift": ("DRIFT?", palette.WARN), + # outcome -> (plan category, action label, style). Covers both the dry-run + # (would_*) and the applied (create/update/drift/…) event vocabularies. + _PLAN_MARKS = { + "would_create": ("create", "create", palette.OK), + "create": ("create", "created", palette.OK), + "would_update": ("update", "update", palette.OK), + "update": ("update", "updated", palette.OK), + "would_noop": ("noop", "noop", "dim"), + "noop": ("noop", "noop", "dim"), + "would_drift": ("drift", "drift — needs force", palette.WARN), + "drift": ("drift", "drift — pushed (force)", palette.WARN), + "drift_skipped": ("drift", "drift — skipped", palette.WARN), + "error": ("error", "error", palette.FAIL), } + def _reset_plan(self) -> None: + self._plan_counts = {} + self.query_one("#scripts-plan", DataTable).clear() + self.query_one("#scripts-plan-summary", Static).update(Text("")) + def _handle_scripts_event(self, ev: dict) -> None: etype = ev.get("event") log = self.query_one("#upload-log", RichLog) if etype == "sync_start": mode = " (dry-run)" if ev.get("dry_run") else "" - self._set_banner(Text(f"syncing {ev.get('fetchers', 0)} script(s) to {ev.get('base_url', '')}{mode}", style=palette.WARN)) + self._set_banner(Text(f"{'preview' if ev.get('dry_run') else 'sync'}: {ev.get('fetchers', 0)} script(s) → {ev.get('base_url', '')}{mode}", style=palette.WARN)) log.write(Text(f"sync {ev.get('fetchers', 0)} fetcher script(s){mode}", style="bold")) elif etype == "sync_item": - icon, style = self._SCRIPT_MARKS.get(ev.get("outcome"), ("?", "white")) - ref = f" set={ev.get('reference_id')}" if ev.get("reference_id") else "" - assoc = " +assoc" if ev.get("associated") else "" - reason = ev.get("reason") or ev.get("error") - suffix = f" {reason}" if reason else "" - log.write(Text(f" [{icon}] {ev.get('fetcher', '?')}{ref}{assoc}{suffix}", style=style)) + self._record_plan_item(ev) + log.write(self._plan_log_line(ev)) elif etype == "sync_complete": self._finalize_scripts(ev) elif etype == "_scripts_failed": @@ -383,33 +440,73 @@ def _handle_scripts_event(self, ev: dict) -> None: log.write(Text(f"scripts sync failed: {ev.get('error', '')}", style=f"bold {palette.FAIL}")) self._set_banner(Text(f"scripts sync failed: {ev.get('error', '')}", style=palette.FAIL)) + def _record_plan_item(self, ev: dict) -> None: + """Add one fetcher's planned/applied action to the plan table + counts.""" + category, label, style = self._PLAN_MARKS.get(ev.get("outcome"), ("other", ev.get("outcome", "?"), "white")) + self._plan_counts[category] = self._plan_counts.get(category, 0) + 1 + assoc = " +assoc" if ev.get("associated") else "" + cell = Text(f"{label}{assoc}", style=style) + self.query_one("#scripts-plan", DataTable).add_row(ev.get("fetcher", "?"), cell) + self._render_plan_summary() + + def _plan_log_line(self, ev: dict) -> Text: + _, label, style = self._PLAN_MARKS.get(ev.get("outcome"), ("other", ev.get("outcome", "?"), "white")) + ref = f" set={ev.get('reference_id')}" if ev.get("reference_id") else "" + assoc = " +assoc" if ev.get("associated") else "" + reason = ev.get("reason") or ev.get("error") + suffix = f" {reason}" if reason else "" + return Text(f" [{label}] {ev.get('fetcher', '?')}{ref}{assoc}{suffix}", style=style) + + def _render_plan_summary(self) -> None: + c = self._plan_counts + total = sum(c.values()) + summary = Text(f"{total} planned", style="dim") + for key, style in (("create", palette.OK), ("update", palette.OK), ("drift", palette.WARN), + ("noop", "dim"), ("error", palette.FAIL)): + if c.get(key): + summary.append(" · ", style="dim") + summary.append(f"{c[key]} {key}", style=style) + if c.get("drift"): + summary.append(" enable force to push drift", style=palette.WARN) + self.query_one("#scripts-plan-summary", Static).update(summary) + def _finalize_scripts(self, ev: dict) -> None: self._syncing = False self._restore_actions() - msg = Text( - "scripts sync complete — " - f"created={ev.get('created', 0)} " - f"updated={ev.get('updated', 0)} " - f"drift={ev.get('drift', 0)} " - f"noop={ev.get('noop', 0)} " - f"associated={ev.get('associated', 0)} " - f"errors={ev.get('errors', 0)}", - style=palette.OK if ev.get("ok") else palette.FAIL, - ) + if ev.get("dry_run"): + # Dry-run counts are zero by design; the plan we accumulated per item + # is the real signal, so summarise from that. + c = self._plan_counts + msg = Text( + "preview complete — " + f"create={c.get('create', 0)} update={c.get('update', 0)} " + f"drift={c.get('drift', 0)} noop={c.get('noop', 0)}", + style=palette.WARN if c.get("drift") else palette.OK, + ) + else: + msg = Text( + "scripts sync complete — " + f"created={ev.get('created', 0)} " + f"updated={ev.get('updated', 0)} " + f"drift={ev.get('drift', 0)} " + f"noop={ev.get('noop', 0)} " + f"associated={ev.get('associated', 0)} " + f"errors={ev.get('errors', 0)}", + style=palette.OK if ev.get("ok") else palette.FAIL, + ) self._set_banner(msg) # -- button state ----------------------------------------------------- # def _disable_actions(self) -> None: - for bid in ("#upload-refresh", "#upload-submit", "#scripts-submit"): + for bid in ("#upload-submit", "#scripts-preview", "#scripts-submit"): self.query_one(bid, Button).disabled = True def _restore_actions(self) -> None: - self.query_one("#upload-refresh", Button).disabled = False self.query_one("#upload-submit", Button).disabled = not (self._preflight and self._preflight.get("ok")) - self.query_one("#scripts-submit", Button).disabled = not ( - self._scripts_preflight and self._scripts_preflight.get("fetcher_count", 0) > 0 - ) + has_fetchers = bool(self._scripts_preflight and self._scripts_preflight.get("fetcher_count", 0) > 0) + self.query_one("#scripts-preview", Button).disabled = not has_fetchers + self.query_one("#scripts-submit", Button).disabled = not has_fetchers def _set_banner(self, renderable) -> None: self.query_one("#upload-banner", Static).update(renderable) diff --git a/framework/tui/screens/workspace.py b/framework/tui/screens/workspace.py index 275f861..13e6619 100644 --- a/framework/tui/screens/workspace.py +++ b/framework/tui/screens/workspace.py @@ -24,6 +24,11 @@ class WorkspaceScreen(Screen): TAB_IDS = ["tab-catalog", "tab-manifest", "tab-run", "tab-evidence", "tab-upload"] + # A one-shot callable that overrides the default focus for the next pane + # activation (used by the search shortcut to land on the filter box instead + # of the pane default). Consumed by _focus_active_pane. + _focus_override = None + # Screen-level bindings shown on every tab's footer (after the page-specific # hints). Keep in sync with BINDINGS below. WORKSPACE_HINTS = [("1-5", "tabs"), ("m", "manifest"), ("q", "quit")] @@ -59,6 +64,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one(CatalogPage).rebuild() self.reload() + # Land focus inside the opening pane so its key bindings are live from + # the first keystroke (not only after the user clicks into it). + self.call_after_refresh(self._focus_active_pane) def reload(self) -> None: """Refresh the manifest-dependent pages + chrome (after load / switch).""" @@ -78,6 +86,9 @@ def reload(self) -> None: def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: self._update_chrome() + # Focus the pane's default on every activation — keyboard *and* mouse — + # so page-level bindings (ctrl+r, etc.) fire however the tab was entered. + self.call_after_refresh(self._focus_active_pane) def _update_chrome(self) -> None: tabs = self.query_one(TabbedContent) @@ -93,19 +104,27 @@ def _update_chrome(self) -> None: break self.query_one(HintFooter).set_hints(page_hints + self.WORKSPACE_HINTS) - def _go_to_tab(self, tab_id: str, focus_default: bool = True) -> None: + def _go_to_tab(self, tab_id: str) -> None: self.set_focus(None) # Textual reverts an active-change while focus is in the outgoing pane self.query_one(TabbedContent).active = tab_id - if focus_default: - self.call_after_refresh(self._focus_active_pane) + # Focus follows via on_tabbed_content_tab_activated (fires for programmatic + # changes too), so this is the single place pane focus is decided. def _focus_active_pane(self) -> None: + # A pending one-shot override (e.g. the search box) wins once, then clears. + override, self._focus_override = self._focus_override, None + if override is not None: + override() + return pane = self.query_one(TabbedContent).active_pane if pane is None: return for child in pane.walk_children(): if hasattr(child, "focus_default"): - child.focus_default() + try: + child.focus_default() + except Exception: + pass # a not-yet-ready pane will be refocused on the next activation return # -- actions ---------------------------------------------------------- # @@ -115,15 +134,23 @@ def action_go_tab(self, index: int) -> None: self._go_to_tab(self.TAB_IDS[index]) def action_unfocus(self) -> None: - self.set_focus(None) + # Escape returns to the pane default rather than clearing focus entirely, + # so global + page bindings both stay live (and a captured Input releases). + self._focus_active_pane() def action_refresh(self) -> None: self.app.refresh_catalog() self.query_one(CatalogPage).rebuild() def action_focus_search(self) -> None: - self._go_to_tab("tab-catalog", focus_default=False) - self.call_after_refresh(lambda: self.query_one(CatalogPage).focus_search()) + self._focus_override = lambda: self.query_one(CatalogPage).focus_search() + tabs = self.query_one(TabbedContent) + if tabs.active == "tab-catalog": + # No TabActivated fires when already here; run the override directly. + self.call_after_refresh(self._focus_active_pane) + else: + self.set_focus(None) + tabs.active = "tab-catalog" # TabActivated → _focus_active_pane consumes the override def action_switch_manifest(self) -> None: self.app.open_manifest_picker() diff --git a/framework/tui/styles/index.tcss b/framework/tui/styles/index.tcss index 9207a3f..1414c9c 100644 --- a/framework/tui/styles/index.tcss +++ b/framework/tui/styles/index.tcss @@ -244,41 +244,41 @@ UploadPage { height: 1fr; } -#upload-top { - height: auto; - padding: 1 1 0 1; - align: left middle; -} - -#upload-top Button { +/* Paramify tab: evidence + scripts panels stacked over a shared log. */ +#evidence-panel { height: auto; margin: 1 1 0 1; } +#evidence-summary { height: auto; } +#evidence-actions { height: auto; align: left middle; padding-top: 1; } + +#scripts-panel { height: 1fr; margin: 1 1 0 1; } +#scripts-header { height: auto; } +#scripts-plan-summary { height: auto; color: $text-muted; } +#scripts-plan { height: 1fr; } +#scripts-actions { height: auto; align: left middle; padding-top: 1; } +/* Uniform SIZE across the whole action row. Buttons and checkboxes size to their + text by default (mismatched widths), and Checkbox keeps its 3-row bordered box + while Button is flattened to 1 row (mismatched heights). Pin both to one width + (fits the longest label, "reassociate") and flatten the checkbox to 1 row so + the row reads as a single band of equal controls. */ +#scripts-actions Button { margin-right: 1; width: 20; } +#scripts-actions Checkbox, +#scripts-actions Checkbox:focus, +#scripts-actions Checkbox:hover { margin-right: 1; + width: 20; + height: 1; + border: none; + padding: 0 1; + background: $panel; } -#upload-options { - height: auto; - padding: 0 1 0 1; - align: left middle; -} - -#upload-options .options-label { - padding: 1 1 0 0; - color: $text-muted; -} +#upload-log-panel { height: 40%; margin: 1 1 0 1; } +#upload-log { height: 1fr; } #upload-banner { - width: 1fr; - padding: 0 0 0 1; -} - -#upload-body { - height: 1fr; - padding: 1 1 0 1; + height: auto; + padding: 0 1; } -#upload-summary-panel { width: 42%; } -#upload-log-panel { width: 1fr; margin-left: 1; } -#upload-summary, #upload-log { height: 1fr; } - /* Modals */ ModalScreen { align: center middle; diff --git a/tests/test_scripts_uploader.py b/tests/test_scripts_uploader.py index 5dfb056..61d1dad 100644 --- a/tests/test_scripts_uploader.py +++ b/tests/test_scripts_uploader.py @@ -99,7 +99,7 @@ def _existing_for_specs(): @pytest.fixture def wired(monkeypatch): monkeypatch.setenv("PARAMIFY_UPLOAD_API_TOKEN", "test-token") - monkeypatch.setattr(uploader, "_discover_specs", lambda root: list(SPECS)) + monkeypatch.setattr(uploader, "_discover_specs", lambda root, include=None: list(SPECS)) fake = FakeClient(_existing_for_specs()) monkeypatch.setattr(uploader, "ParamifyScriptsClient", lambda token, base_url: fake) return fake @@ -150,7 +150,7 @@ def test_https_guard_rejects_http(): def test_error_isolation(monkeypatch): monkeypatch.setenv("PARAMIFY_UPLOAD_API_TOKEN", "test-token") - monkeypatch.setattr(uploader, "_discover_specs", lambda root: list(SPECS)) + monkeypatch.setattr(uploader, "_discover_specs", lambda root, include=None: list(SPECS)) fake = FakeClient(_existing_for_specs()) def boom(name, description, code): @@ -167,3 +167,37 @@ def boom(name, description, code): # the other fetchers still processed assert r["f_bump"]["outcome"] == "update" assert r["f_noop"]["outcome"] == "noop" + + +# --------------------------------------------------------------------------- # +# Manifest scoping: discovery restricts to the `include` set (the manifest's +# fetchers), so a sync never provisions scripts for fetchers you don't collect. +# --------------------------------------------------------------------------- # + +def test_include_scopes_discovery_to_named_fetchers(): + # demo_hello is credential-free and declares an evidence_set, so it always + # discovers; scoping to it must yield exactly it. + only = uploader._discover_specs(REPO_ROOT, include={"demo_hello"}) + assert {s["fetcher_name"] for s in only} == {"demo_hello"} + + # None = the whole catalog (many more than one). + everything = uploader._discover_specs(REPO_ROOT, include=None) + names = {s["fetcher_name"] for s in everything} + assert "demo_hello" in names and len(names) > 1 + + # A name that isn't a real fetcher scopes to nothing. + assert uploader._discover_specs(REPO_ROOT, include={"not_a_fetcher"}) == [] + + +def test_sync_forwards_include_to_discovery(monkeypatch): + monkeypatch.setenv("PARAMIFY_UPLOAD_API_TOKEN", "test-token") + seen = {} + + def fake_discover(root, include=None): + seen["include"] = include + return list(SPECS) + + monkeypatch.setattr(uploader, "_discover_specs", fake_discover) + monkeypatch.setattr(uploader, "ParamifyScriptsClient", lambda token, base_url: FakeClient(_existing_for_specs())) + uploader.sync_scripts(".", include={"f_bump"}) + assert seen["include"] == {"f_bump"} diff --git a/uploaders/paramify_scripts/uploader.py b/uploaders/paramify_scripts/uploader.py index 93a5d04..8847094 100644 --- a/uploaders/paramify_scripts/uploader.py +++ b/uploaders/paramify_scripts/uploader.py @@ -195,14 +195,20 @@ def _resolve_reference(fetcher_name: str, es: Dict, overrides: Dict) -> Dict: } -def _discover_specs(root: Path) -> List[Dict]: +def _discover_specs(root: Path, include: Optional[set] = None) -> List[Dict]: """Build one script spec per fetcher that declares an evidence set and has a readable entry file. Discovery lives here (not the client) so the client stays - pure API I/O.""" + pure API I/O. + + ``include`` restricts the sweep to fetchers whose name is in the set (the + fetchers a manifest actually uses); ``None`` means every discovered fetcher. + """ from framework.config_loader import discover_fetchers # lazy: repo-side only specs: List[Dict] = [] for f in sorted(discover_fetchers(root).values(), key=lambda x: x.name): + if include is not None and f.name not in include: + continue if not f.evidence_set: logger.warning("%s: no evidence_set; skipping (nothing to associate a script to)", f.name) continue @@ -237,6 +243,7 @@ def sync_scripts( dry_run: bool = False, force: bool = False, reassociate: bool = False, + include: Optional[set] = None, on_event: Optional[Callable[[dict], None]] = None, ) -> Dict: """Reconcile every fetcher's entry script into Paramify and associate it. @@ -265,7 +272,7 @@ def sync_scripts( raise ValueError(msg) root = Path(root) - specs = _discover_specs(root) + specs = _discover_specs(root, include) logger.info("Syncing %d fetcher script(s) → %s%s", len(specs), base_url, " (dry-run)" if dry_run else "") _emit(on_event, {"event": "sync_start", "base_url": base_url, "dry_run": dry_run, "fetchers": len(specs)}) @@ -389,6 +396,8 @@ def main(argv=None) -> int: parser.add_argument("--dry-run", action="store_true", help="Report the plan; read-only (no writes)") parser.add_argument("--force", action="store_true", help="Push scripts whose code drifted without a version bump") parser.add_argument("--reassociate", action="store_true", help="Ensure the script↔evidence-set association for every fetcher, not just changed ones") + parser.add_argument("--manifest", help="Sync only the fetchers this manifest uses (default: manifest.yaml at the repo root)") + parser.add_argument("--all", dest="all_fetchers", action="store_true", help="Sync every fetcher in the repo, not just the manifest's") args = parser.parse_args(argv) if args.root: @@ -397,6 +406,15 @@ def main(argv=None) -> int: from framework.api import find_repo_root root = find_repo_root() + # Default to the manifest's fetchers; --all provisions the whole catalog. + include = None + if not args.all_fetchers: + from framework.api import read_manifest + mpath = Path(args.manifest) if args.manifest else (root / "manifest.yaml") + manifest = read_manifest(mpath) + entries = (manifest.get("run") or {}).get("fetchers") or [] + include = {e.get("use") for e in entries if e.get("use")} + try: summary = sync_scripts( root, @@ -404,6 +422,7 @@ def main(argv=None) -> int: dry_run=args.dry_run, force=args.force, reassociate=args.reassociate, + include=include, ) except ValueError: return 1