Skip to content

test(routes/fs): cover the directory-listing endpoint (26% → 93%)#7

Merged
silversurfer562 merged 1 commit into
mainfrom
test/fs-coverage
May 4, 2026
Merged

test(routes/fs): cover the directory-listing endpoint (26% → 93%)#7
silversurfer562 merged 1 commit into
mainfrom
test/fs-coverage

Conversation

@silversurfer562
Copy link
Copy Markdown
Member

Summary

attune_gui.routes.fs coverage jumps from 26% to 93% (per the deep-review's second-biggest gap and the most security-sensitive route).

12 new tests grouped into:

  • Happy path (5): subdirectory listing, file exclusion, resolved absolute path, parent handling, case-insensitive sort
  • Hidden-entry policy (2): default suppression, allow-list of `.help` and `.attune`
  • Tilde expansion (2): `~` and default-path-is-home
  • Error paths (3): nonexistent → 400, file → 400, PermissionError → 403

Test plan

  • `uv run pytest sidecar/tests/test_fs.py` — 12 passing
  • `uv run pytest sidecar/tests/` — 168 passing total (no regressions)
  • `uv run ruff check` — clean
  • Coverage on `attune_gui.routes.fs`: 93% (was 26%)

🤖 Generated with Claude Code

Adds 12 tests for /api/fs/browse covering the security-sensitive
directory listing logic:

Happy path:
  - Lists subdirectories, excludes files
  - Returns resolved absolute path
  - Sets parent (and None at filesystem root)
  - Sorts entries case-insensitively

Hidden-entry policy:
  - Suppresses dot entries by default
  - Allow-lists .help and .attune (still hides .git)

Path expansion:
  - Tilde (~) expands to $HOME
  - Default path is $HOME

Error paths:
  - Nonexistent path -> 400 ("Not a directory")
  - File path -> 400 ("Not a directory")
  - PermissionError during iterdir -> 403

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@silversurfer562 silversurfer562 merged commit b0243e5 into main May 4, 2026
@silversurfer562 silversurfer562 deleted the test/fs-coverage branch May 4, 2026 13:24
silversurfer562 added a commit that referenced this pull request May 5, 2026
* feat: editor server routes for template-editor (M2 partial)

Implements 4 of 6 M2 tasks from specs/template-editor:

- task #7  corpora registry at ~/.attune/corpora.json (load/save +
           list/active/register/resolve helpers) and /api/corpus/*
           routes; resolve picks deepest matching root for nested
           corpora; register is idempotent on duplicate paths
- task #8  sidecar portfile at ~/.attune/sidecar.port with
           {pid, port, token}; portfile_context lifecycle helper;
           is_pid_alive / is_portfile_stale freshness checks; new
           top-level GET /healthz?token route returns 401 on mismatch
- task #10 template GET / diff / save: optimistic concurrency via
           base_hash (409 on drift), atomic write via tempfile +
           os.replace, per-hunk save via accepted_hunks list, path-
           traversal blocked at the route boundary
- task #11 lint + autocomplete proxies that delegate to
           attune_rag.editor; lazy imports keep cold-start fast

Tasks #9 (EditorSession + watchfiles) and #12 (WebSocket + rename
refactor routes) remain — both are async-heavy and best done in a
fresh session.

Adds per-file ruff ignores S105/S106 in tests for fake token fixtures.

30 new tests; full sidecar suite 317 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: editor session + WebSocket + rename refactor (M2 #9, #12)

Closes the last two M2 tasks for specs/template-editor. With this
commit, M2 is complete (tasks 7-12).

- task #9  EditorSession dataclass: per-tab state holding base_text,
           base_hash, draft_text. Asyncio polling watcher (100ms)
           emits {type: file_changed, new_hash} via an event queue,
           deduped on hash. load / update_draft / current_disk_hash /
           matches_base / rebase / start / stop / next_event. Polling
           rather than watchfiles for testability — same external
           behavior, simpler mocking.

- task #12 WebSocket /ws/corpus/<id>?path=<rel>: primary tab owns an
           EditorSession; second tab on the same (corpus, path) gets
           {type: duplicate_session} and stays read-only. Module-level
           subscriber registry GC'd when the WS closes. Path traversal
           and unknown corpus rejected at the handshake (policy-
           violation close).

           rename refactor routes /api/corpus/<id>/refactor/rename/
           {preview,apply} proxy to attune_rag.editor.plan_rename /
           apply_rename. Collisions surface as 409; OSError mid-stream
           returns 500 (apply_rename has already rolled the partial
           writes back). Apply broadcasts file_changed to subscribers
           on affected paths.

origin_guard widened from Request to HTTPConnection so the same
dependency covers both HTTP and WebSocket routes.

17 new tests (7 EditorSession unit + 10 WS/rename route); full sidecar
suite 334 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(main): write portfile during sidecar startup (template-editor M5)

Wraps uvicorn.run in editor_sidecar.portfile_context so the portfile
at ~/.attune/sidecar.port is created on startup and removed on
shutdown. Required for attune-author's `edit` CLI to discover a
running sidecar without scanning ports.

Implements the launcher integration deferred from task #8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): scaffold Vite frontend pipeline (template-editor M3 #13)

Add `editor-frontend/` as a Vite 6 + TypeScript 5.7 project that produces
a deterministic, pre-bundled `editor.js` + `editor.css` into
`sidecar/attune_gui/static/editor/`. The bundle is committed so the wheel
ships without requiring a Node toolchain at install time, matching the
existing attune-gui pattern.

- Build entry: `src/main.ts` (placeholder mount; CodeMirror lands in #15).
- `make build-editor` runs `npm ci && npm run build`; lint/typecheck/dev
  targets included.
- ESLint flat config + tsc strict mode; both clean on the placeholder.
- App factory mounts `/static/editor` from the bundle dir when present.
- sdist now ships `editor-frontend/` sources + `Makefile` so source
  distributions can rebuild.
- `.gitignore` retires the legacy React UI ignore (retired in 0.5.0)
  and ignores `editor-frontend/node_modules` + Vite caches.

Verified: full pytest suite (334 passed); ruff clean; bundle sizes
0.28 KB JS / 0.13 KB CSS gzipped (well under the 600 KB budget).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): grammar + /editor mount (template-editor M3 #14, #15)

Task #14: Lezer Markdown extension for Attune templates.

- `editor-frontend/src/grammar/markdown-extension.ts` defines a
  `MarkdownConfig` with three custom node types:
    - `AttuneFrontMatter`  — leading `---` ... `---` block (only at
      doc start; falls back to plain markdown if unterminated).
    - `AttuneDepthMarker`  — heading lines matching `^#{1,6}\s+Depth\s+\d+`
      (case-insensitive). Runs before `ATXHeading` so it claims first.
    - `AttuneAliasRef`     — inline `[[alias]]`. Runs before `Link`,
      reads `\[[` as escape, rejects newlines / nested `[`. Inline
      parsers do not run inside fenced code, so fence-exclusion is free.
- 14 vitest fixtures cover the grammar end-to-end.

Task #15: `/editor` Jinja2 shell + frontend mount.

- `routes/editor_pages.py` (`GET /editor?corpus=&path=`) renders a
  declarative `templates/editor.html` shell carrying `corpus_id`,
  `rel_path`, and the per-process session token as `data-*` attrs.
- Frontend modules:
    - `api.ts` — typed fetch helpers (`loadTemplate`, `saveTemplate`,
      `ApiError`).
    - `document-model.ts` — `splitFrontMatter`/`parseFrontMatter`/
      `serializeFrontMatter`/`combine` plus `TemplateDocument`, the
      single source of truth round-tripping between form and editor.
      12 unit tests cover scalars, flow arrays, key-order preservation,
      add/remove field, and full text round-trips.
    - `editor.ts` — CodeMirror 6 mount (history, default keymap,
      markdown + Attune extension, foldGutter, diffGutter, theme).
    - `diff-gutter.ts` — jsdiff-based added/modified/removed markers
      with a `StateField` + `gutter` extension. Modify-then-add
      pairings collapse into a single `M` marker.
    - `frontmatter-form.ts` — hand-rolled inputs for the well-known
      fields (type/name/tags/aliases/summary). Schema-driven rendering
      lands in #16; this is the surface the next iteration plugs into.
    - `main.ts` — bootstraps layout (top bar / sidebar / editor pane /
      diagnostics strip), fetches the template, wires bidirectional
      form↔editor binding with re-entrancy guards.
- Bundle: 524.87 KB raw / 182.55 KB gzip (under the 600 KB budget).
- Live verification with the real `attune-help` corpus: editor renders,
  frontmatter form populated, name edit reflects into CodeMirror, diff
  gutter paints a `modified` marker on the changed line. No console
  errors. Full pytest suite (334) + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): lint + autocomplete extensions (template-editor M3 #17, #18)

Task #17: live linting in the editor pane and a clickable diagnostics
strip below it.

- `lint.ts` wraps `@codemirror/lint`'s `linter()` with a 300 ms debounce,
  an AbortController so superseded requests cancel, and unions a
  client-side fast-path (unterminated frontmatter) with the authoritative
  server response from `POST /api/corpus/<id>/lint`.
- Server diagnostics are 1-indexed line/col; `lineOffsets` + `toRange`
  convert to CM offsets with empty-range padding so squiggles always
  paint.
- `diagnostics-strip.ts` renders a clickable list (severity icon, line
  number, message, `attune:<code>` source) bound to the lint callback.
  Clicking jumps the editor selection + scrollIntoView; Enter / Space
  works for keyboard nav. Server-down degrades to a single info
  diagnostic instead of a hard failure.
- 3 vitest fixtures cover the local fast-path.

Task #18: autocompletion inside `[[…]]` body refs and `tags:`/`aliases:`
frontmatter fields.

- `autocomplete.ts` `inferContext()` walks back from the cursor for the
  `[[` body case (any pos in any doc) and otherwise detects the
  `tags:`/`aliases:` line inside the leading frontmatter block. Handles
  flow arrays (`[a, b`), bare scalar form (`tags: alpha`), and
  unterminated frontmatter so completion fires while the user is still
  typing the opening.
- Results call `GET /api/corpus/<id>/autocomplete?kind=&prefix=`. Tag
  results are plain strings; alias results carry `template_name`
  (`detail`) and `template_path` (`info`) so the popup is informative.
- `AutocompleteCache` keys by `(kind, casefolded prefix)` and exposes
  `invalidateCache()` for M3 #20's WS file-change wiring.
- 10 vitest fixtures cover `inferContext` + the cache.

Plumbing:

- `api.ts` gains `lint()` and `autocomplete()` with abort signal
  support and `X-Attune-Client` for the mutating lint POST.
- `editor.ts` accepts an `extra: Extension[]` and `main.ts` injects
  both extensions plus wires the diagnostics strip.
- `style.css` adds the diagnostics list styling.

Live verification against the real attune-help corpus:
- Unedited template surfaces a single info diagnostic (`unknown-field`
  on line 5: the existing `source:` key isn't in the schema yet).
- Appending `[[no-such-alias-xyz]]` produces a `broken-alias` error,
  paints a `cm-lintRange` squiggle, and the strip-click jumps the
  cursor to line 155.
- `[[ci` in body opens the popup with real alias matches
  (`ci failure → tool-fix-test`, etc).

Bundle: 582 KB raw / 201 KB gzip (still under 600 KB budget).
Full pytest suite (334) passes; ruff clean; eslint + tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): per-hunk save flow (template-editor M3 #19)

End-to-end save: top-bar Save button + ⌘/Ctrl-S → modal with the
server-computed unified diff → click Save → atomic write via the
existing /template/save endpoint.

- `save-flow.ts` (`parseHunkHeader`, `applyAcceptedHunks`,
  `saveButtonLabel`) ports `_apply_accepted_hunks` from the server one
  for one. The client-side projection is what drives the per-hunk
  preview and the projected-state lint, so the two stay in sync with
  the eventual server write. 13 vitest fixtures cover the no/all/h1-
  only/h2-only/no-trailing-newline cases plus header parsing and label
  generation.
- `save-modal.ts` renders each hunk with a default-checked checkbox
  and a colorized unified-diff body. Toggling a checkbox recomputes
  the projection, calls `POST /lint`, and re-paints a blocking banner
  for any frontmatter parse error from a small allowlist of codes
  (`missing-required`, `bad-enum`, `bad-type`, `too-short`,
  `duplicate-items`, `not-a-mapping`, `malformed-yaml`). Save button
  label adapts: "No changes" / "Save N of M hunks" / "Save 1 hunk" /
  "Save all N hunks". `Esc` cancels; ⌘/Ctrl-Enter saves.
- `api.ts` gains `diffTemplate`.
- `main.ts` adds the top-bar Save button + ⌘/Ctrl-S keybinding,
  re-disables the button when the draft equals base (via a CM
  `updateListener`), shows a non-dismissible conflict banner with a
  "Reload from disk" button when /save returns 409 (full 3-way merge
  is M4 #20), and warns on `beforeunload` if there are unsaved edits.
  After every successful save the editor re-fetches the file so
  partial saves leave the editor's base accurate while preserving any
  unaccepted hunks in the draft.
- `style.css` adds modal / banner / toast / hunk-body / button styles.

Live-verified end-to-end on a throwaway temp corpus:

  1. Edit + Save → file changed on disk; original attune-help source
     untouched. Toast: "Saved (1 of 1 hunks).". Status updated to the
     new base hash; save button re-disabled.
  2. Multi-hunk edit (title + tail) → unchecking the title hunk
     flipped the label to "Save 1 of 2 hunks" → save → only the tail
     hunk landed; title left as the un-applied draft delta.
  3. Invalid `type: not-a-real-type` → projected lint blocked save
     with "Cannot save — 1 frontmatter error in projected state:
     line 2: Field 'type' must be one of: 'concept', 'task',
     'reference', 'guide'". Save button correctly greyed out.

Bundle 589 KB raw / 204 KB gzipped (still under 600 KB budget).
52 frontend tests (13 new save-flow). Full pytest suite (334) +
ruff + eslint + tsc all clean. No console errors during the live run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): schema-driven frontmatter form (template-editor M3 #16)

Closes M3.

Server:
- New `routes/editor_schema.py` serves `GET /api/editor/template-schema`
  via `attune_rag.editor._schema.load_schema()`. Lazy import keeps cold
  start fast.
- 1 new pytest fixture in `test_editor_schema.py`.

Frontend (rewrote `frontmatter-form.ts` end to end):
- Walks `schema.properties` and dispatches by shape:
    - `enum`              → `<select>` (with a "—" empty option for
                              non-required fields)
    - `type === "array"`  → chip input — pills + a typing field with
                              Enter/comma to add, Backspace to remove
                              the last chip
    - `name === "summary"` or `x-attune-multiline: true`
                          → `<textarea>` (3 rows)
    - otherwise           → `<input type="text">`
- Required fields get a red `*`. Property `description` becomes the
  input `title` for hover tooltips.
- Unknown frontmatter keys (anything not in `schema.properties`) get a
  read-only "Other fields" section at the bottom of the form so they
  stay visible but don't invite accidental editing.
- "Raw YAML" toggle replaces the form with a textarea showing the
  frontmatter block. Both views write through the same `TemplateDocument`
  instance — the round-trip is byte-identical when no field-level edits
  were made (already enforced by the existing TemplateDocument tests).
- Initial-value seed bug fix: `renderTyped()` now calls `refresh()` on
  every renderer after construction so values from the document show
  up on first render and on every Raw-YAML toggle.

Plumbing:
- `api.ts` adds `loadSchema()` + `TemplateSchema` types.
- `main.ts` fetches schema in parallel with the template on first paint
  and passes it through to the form.

Cache-bust: bundle URLs now carry `?v=<editor.js-mtime>` so the browser
always picks up a fresh build. Falls back to `?v=dev` when the bundle
hasn't been built yet.

Live-verified end-to-end on the real attune-help corpus:
- Initial render shows correct values for all known fields plus the
  unknown `source: plugin/skills/planning/SKILL.md` in "Other fields".
- Raw → form round-trip: edited `name` in raw YAML, toggled to form,
  the input picked up the new value and CodeMirror's frontmatter line
  matches.
- Form → raw round-trip: `type` select change + chip add visible in
  the raw YAML textarea after toggling.

335 pytest pass (1 new); 52 frontend tests pass; ruff + eslint + tsc
all clean. Bundle 594 KB raw / 205 KB gzipped (still under the
600 KB budget). No console errors during the live run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(editor): hashed bundle filenames + advisory + Haiku review fixes

Three improvements layered on M3:

1. Bundle filenames now carry a content hash (Vite `manifest: true` +
   `entryFileNames: editor-[hash].js`). The Python route reads
   `.vite/manifest.json` on each request and passes the hashed JS/CSS
   names through to the Jinja shell, so cache invalidation is now
   correct-by-construction rather than relying on an mtime query
   string. Falls back to legacy unhashed names + a `dev.{js,css}`
   sentinel so the page still renders before the first build.

2. Save modal now distinguishes blocking vs advisory diagnostics. The
   prior allowlist of frontmatter codes still hard-blocks save (the
   user must not be able to write known-broken YAML). Other
   error/warning diagnostics from the projected lint — `broken-alias`
   is the canonical one — render in a yellow advisory banner under
   the diff. Save still proceeds. Reasoning: drafts that reference an
   alias the user is about to create are common and forcing a
   create-then-save dance is friction. The advisory makes the issue
   visible without standing in the way.

3. Acted on a focused Haiku second-eyes review:
   - [bug] B3: `lint.ts:toRange` could synthesize a `{from:0, to:1}`
     range on an empty document. Added an early `text.length === 0`
     guard and a final clamp so neither bound exceeds doc length.
   - [smell→non-issue] C2: Haiku flagged that completion in an
     unterminated-frontmatter's last line might be rejected by the
     `lineIdx >= fm.close` check. Walked through the math — the
     check is correct (last line is `length-1`, not `length`), but
     added a pinned vitest fixture so future regressions land
     visibly.
   - [unverified→already-covered] A3 (multi-hunk apply): existing
     `save-flow.test.ts` h1-only/h2-only fixtures cover the case
     Haiku flagged. No change.

Bundle 594 KB raw / 205 KB gzip (unchanged); 53 frontend tests pass
(11 autocomplete now incl. C2 regression); 335 attune-gui pytest
pass; ruff + eslint + tsc clean; no console errors during the live
verification (advisory + blocking banner co-existence + hashed
bundle URL all confirmed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(editor): loosen schema-endpoint enum check

The pinned enum list moved into attune-rag's own schema test
(`test_type_enum_covers_all_corpus_kinds`) when the corpus drift
fix landed. Here we only need to confirm the proxy returns the
schema intact and a representative slice of kinds is present —
spot-check the original four plus the kinds added by the drift
fix (quickstart / faq / warning).

Tracks attune-rag commit 06ebcec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): M4 frontend extras — conflict mode, rename, switcher, polish

Wraps up the template-editor M4 milestone (tasks #20#23).

#20 conflict mode (`ws.ts`, `three-way-merge.ts`, `conflict-mode.ts`):
auto-reconnecting WebSocket on `/ws/corpus/<id>?path=<rel>` listens for
`file_changed` and `duplicate_session` pushes from the sidecar. On a
conflict (or 409 from save), a non-dismissible banner offers Reload /
Keep / Resolve. Resolve uses `node-diff3` for per-region accept-disk /
accept-editor / keep-both, then rebases the editor to the new disk hash.

#21 rename refactor (`rename-modal.ts` + chip context-menu in
`frontmatter-form.ts`): right-click any tag/alias chip → "Rename …".
The modal debounces 250ms, calls `/refactor/rename/preview`, renders a
multi-file diff. Apply posts `/refactor/rename/apply` and surfaces a
toast listing affected files. 409 with `owning_path` becomes a
collision banner.

#22 corpus switcher (`corpus-switcher.ts`): top-bar dropdown lists
registered corpora from `/api/corpus`. `+ Add corpus…` registers a new
root via `/api/corpus/register`. Search input materializes once
registered count > 10. Trigger label uses the *editing* corpus while
the dropdown's `✓ active` badge tracks the registry's active —
prevents mislabel when boot ≠ active. Switching with unsaved edits
prompts Save / Discard / Cancel.

#23 polish (`advisory-banner.ts`): persistent strip above the conflict
banner — generated-corpus warning when active corpus has
`kind: "generated"`, plus `duplicate_session` read-only notice (moved
out of the transient conflict banner so they no longer clobber each
other). Cmd/Ctrl-K wired to a "Command palette: coming in v2" toast.
beforeunload skipped on duplicate-session tabs (no edits possible).

Tests: 23 new vitest cases (three-way-merge: 18, rename-modal: 6,
corpus-switcher: 11, advisory-banner: 6) — full suite 94 passing.
ESLint + tsc clean. node-diff3 added as a runtime dep
(zero indirect deps, ~40 KB).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(editor): document the template editor + rebuild bundle for M4

Adds a developer-focused "Template editor (/editor)" section to the
README covering CodeMirror integration, schema-driven form, server
lint/autocomplete, per-hunk save flow, 3-way merge conflict mode,
cross-corpus rename refactor, corpus switcher, and the advisory
banner — plus the full editor endpoint summary and the
editor-frontend dev loop.

Bundle rebuilt with the M4 changes:
- editor-bnzzsKJv.js  (607 KB / ~210 KB gzipped — under the 600 KB
  gzipped budget set in the spec)
- editor-DWjgUsT4.css (13 KB)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(editor): read-only hash field + ATTUNE_CORPORA_REGISTRY override

Two small additions, both prep for the Playwright e2e suite landing
in the next commit.

**Read-only hash field**: `TemplateSchemaProperty.readOnly?: boolean`
in `api.ts`; `attachInput` now flips the input to `readOnly` (or
`disabled` for `<select>`) and tags the row with
`attune-fm-row-readonly`. CSS adds a muted background and a
"(read-only)" suffix on the label. The `hash` field flips on once
the schema declares `readOnly: true` (attune-rag side).

**ATTUNE_CORPORA_REGISTRY env override**: the registry path was
hard-coded to `~/.attune/corpora.json`. Tests and CI need to point
elsewhere so they don't trample the user's real registry. Now read
through `_registry_path()`, which honors the env var if set.
`test_editor_corpus.py` uses `monkeypatch.setenv` instead of patching
the (now-private) module attribute.

Bundle rebuilt for the form changes — still ~210 KB gzipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(editor): Playwright e2e for the four golden flows

Locks the four end-to-end editor flows into CI so the M4 work can't
silently regress:

  1. open → edit → save (per-hunk save modal, atomic write to disk)
  2. external disk edit → WS-pushed `file_changed` → conflict mode
     resolve with "Keep both" → editor rebases to new disk hash
  3. right-click chip → rename refactor preview → apply rewrites
     every reference on disk
  4. dirty editor + corpus switcher → unsaved-edits prompt with
     Save/Discard/Cancel; Cancel preserves state; Discard navigates

Infra:

- `playwright.config.ts` spawns the sidecar with
  `ATTUNE_CORPORA_REGISTRY=e2e/.tmp/corpora.json` so tests are
  isolated from the user's real registry. Readiness check uses
  `/editor` (returns 200 without auth) since `/healthz` requires a
  token. Sequential workers because the sidecar process is shared.
- `e2e/helpers.ts` writes per-test fixture corpora into a temp dir
  via `setupCorpus`, registers them via `/api/corpus/register`,
  waits for the WS to reach `OPEN` before tests that depend on
  push events, and exposes a `clearRegistry()` hook called in
  every spec's `beforeEach` so registry state doesn't leak.
- `vitest.config.ts` excludes `e2e/` from the unit-test pass
  (specs use `@playwright/test`, not vitest).
- `npm run e2e` / `e2e:headed` / `e2e:ui` scripts.
- Chromium downloaded via `playwright install chromium` (one-off).

Suite runs in ~3.3s locally and adds the four flows to the README's
editor dev-loop section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(editor): exhaustive pre-publish coverage — every UI control + every endpoint

Five new Playwright spec files covering every interactive editor
control beyond the four golden flows already in 01–04:

- **05-save-modal-controls** — partial save (uncheck a hunk; only
  the checked hunks land on disk), zero-hunk disables submit,
  Cancel writes nothing, projected-state lint blocks invalid
  frontmatter (missing required `name`).
- **06-conflict-choices** — every entry button (Reload, Keep,
  Resolve) and every per-conflict choice (Use disk, Use editor,
  Cancel resolve modal). Done-disabled-until-all-conflicts-resolved
  gating.
- **07-rename-edge-cases** — alias collision banner with
  `owning_path`; same-name no-op summary; Cancel writes nothing;
  zero-references no-op plan.
- **08-corpus-switcher-extras** — search input materializes above
  SEARCH_THRESHOLD (>10 corpora), filters live; Add-corpus modal
  (Cancel + happy path); read-only `hash` field (typing has no
  effect, `readonly` attribute set).
- **09-keyboard-and-advisories** — Cmd/Ctrl-S opens save modal,
  Cmd/Ctrl-K shows v2-coming toast, Esc closes modals,
  generated-corpus advisory for `kind: "generated"`, Save button
  disabled-until-dirty, diagnostics-strip click jumps the editor.

Also adds **`scripts/smoke_editor_api.py`** — a stdlib-only Python
script that spins up the sidecar against an isolated registry +
fixture corpus and hits every editor endpoint with valid AND
invalid inputs (27 checks: registry CRUD + active switching, schema
fetch with hash.readOnly verification, template GET/diff/save with
path-traversal + 409 hash-mismatch + unknown-corpus paths, lint
broken-alias detection, autocomplete + bogus-kind 422, rename
preview/apply for alias kinds + collision + template_path 400 +
same-name no-op, healthz token gating). Designed for pre-publish
gating: exits non-zero on any unexpected status.

All 27 e2e tests pass in ~12 s; all 27 smoke checks pass in ~1 s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(commands): Run button now opens an arg-form modal instead of
posting empty args

The previous Commands page rendered each command as a card with a
single "Run" button that fired `POST /api/jobs` with `args: {}` —
no matter what the command's `args_schema` required. So
`author.generate` (and any other command with a required arg)
always 400'd with "Missing required args: feature" because the UI
never gave the user a place to type one.

This commit:

1. **Renders a form modal** when the user clicks Run on a command
   whose schema has `properties`. One typed input per property
   (text / number / checkbox / select for enums / textarea for
   `ui:widget: textarea`), with required-field stars, default-
   value pre-fill, and the property `description` as inline help.
   Native HTML `required` validation gates submit.

2. **Builds args correctly per type** — booleans from
   checkbox.checked, numbers/integers parsed with NaN check,
   strings trimmed; empty optional values are dropped (rather than
   sent as ""), empty optional values with a non-empty default
   substitute the default. Missing required strings surface a
   "Missing or invalid: <fields>" banner before the request fires.

3. **Preserves the one-click flow** for commands with no
   `properties` (none today, but a fallback for future no-arg
   commands).

4. **Fixes the schema embed**. The schema is now in a
   `<script type="application/json" class="cmd-schema">` tag per
   card; reading it via `JSON.parse(scriptTag.textContent)` is
   robust to the JSON's quote characters.

   The original broken embed used `data-schema="{{ x | tojson | e }}"`,
   which doesn't compose: Jinja's `tojson` returns Markup-safe
   content that bypasses `|e`, so unescaped `"` characters truncated
   the attribute at the first quote. The browser then saw only `{`,
   parsing failed silently, and the click handler took the
   "no-properties → POST empty" fallback. That's the root cause of
   the failure the user hit.

5. **Adds a regression test** in `test_cowork_pages.py`. Each
   embedded schema must parse, must include the expected shape for
   `author.generate`, and the page must not contain a raw
   `data-schema=` attribute (which would hint at a regression to
   the broken embed).

Verified end-to-end in the browser: clicking Run on `author.generate`
now opens a modal with the five form fields; submitting with a
populated `feature` posts the full args object to `/api/jobs`;
submitting empty triggers native HTML required-field validation
before the request fires.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(commands): live datalist suggestions via `ui:choicesUrl`

Followup to the Commands-page form modal. Some args (`feature` on
`author.generate` / `author.regen` and similar) have a finite,
manifest-driven set of valid values. Hardcoding them in the schema
is wrong — they vary per project. Free-text traps users into typos
that surface as runtime job errors (the user just hit this with
`Feature 'test' not in manifest. Available: …`).

Adds a generic mechanism: a property with a `ui:choicesUrl`
extension points at an endpoint that returns `{choices: [...]}`. The
form fetches the URL when the modal opens (with `{otherField}`
placeholders substituted from sibling input values), populates a
`<datalist>`, and refetches when a referenced field changes.

Pieces:

- **New endpoint** `routes/choices.py`:
  `GET /api/author/features?help_dir=<dir>` (or `?project_path=<root>`,
  resolved to `<root>/.help/`). Returns `{choices: [feature_names…]}`.
  404 if the dir or manifest is missing; 400 on malformed manifest
  or ambiguous query (both/neither arg).

- **Schema extension** on `author.generate` and `author.regen` —
  the `feature` property now carries
  `"ui:choicesUrl": "/api/author/features?help_dir={help_dir}"` (or
  `project_path` for `regen`, matching that command's form shape).

- **Form renderer** in `commands.html`:
  - `expandChoicesUrl(url)` substitutes `{name}` placeholders from
    the form's other inputs; aborts when a required placeholder is
    empty (so the user gets "fill the related field first" rather
    than a 400).
  - `wireChoicesEndpoints()` fetches each datalist source on modal
    open and rebinds an `input` event listener that refetches when
    a referenced field changes.
  - Per-state placeholders ("4 suggestions — type or pick", "(no
    suggestions — 404)", etc.) tell users what's going on without a
    blocking modal.

- **8 new tests** in `test_choices.py`: help_dir + project_path
  variants, neither-arg/both-args 400s, missing dir/manifest 404s,
  malformed manifest 400, plus a regression test that asserts the
  `ui:choicesUrl` extension stays attached to the relevant
  command schemas (so a future cleanup doesn't silently revert
  the field to a free-text input).

Verified end-to-end: opening the `author.generate` modal now
populates the Feature dropdown with the four feature names from
`sidecar/.help/features.yaml`; changing `help_dir` to a bogus path
clears the suggestions; switching back repopulates them.

Server suite: 336 → 344 passing. Ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(commands): Browse… buttons + directory picker modal for path
inputs

The Commands form had string text inputs for `help_dir` /
`project_root` / `project_path` with placeholder
"Path (absolute or relative to sidecar cwd)". Typing absolute
paths blind is user-hostile and easy to get wrong — relative paths
silently resolve against the sidecar's CWD instead of the project
root, which is hard to tell from a typo and easy to misconfigure.
The user explicitly asked for pickers.

This adds:

- A `Browse…` button next to every input with `ui:widget: "path"`,
  laid out via a flex `.cmd-path-wrap` so the button stays
  right-anchored without distorting the input on narrow screens.
- A directory picker modal lazily inserted on first Browse click
  (single instance, reused across all path fields). Uses the
  existing `/api/fs/browse` endpoint — no new server route needed.
  The endpoint already returns directories only and surfaces the
  `.help` / `.attune` hidden allowlist, which is exactly what the
  picker wants.
- Navigation: an `↑` button moves to the parent (disabled at
  filesystem root); clicking a subdirectory descends. Currently
  selected path shown as a `<code>` breadcrumb at the top.
- `Choose this directory` writes the current path back to the
  target input AND fires an `input` event so any downstream
  bindings rebind — notably the `ui:choicesUrl` datalist for
  `feature` re-fetches when `help_dir` / `project_path` change.
- Esc / Cancel / overlay-click close without writing.

The picker is dir-only by design — every current `path` widget in
the command schemas is a directory. If/when a future command takes
a single file, we can extend `/api/fs/browse` with an
`include_files` flag.

Verified end-to-end: opened the `author.generate` form, clicked
Browse on `.help/ path`, picker showed the contents (including the
allow-listed `.help/`), navigated up, picked a different dir,
target input updated, and the feature datalist refetched
suggestions for the new `help_dir`.

Server suite: 344 → 345 passing (one regression test confirming
`Browse…` wiring + `ui:widget: "path"` declaration on the
relevant fields). Ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(commands): picker now flags `.help/` dirs with a manifest badge

The directory picker showed every subdirectory the same way. The user
hit a real failure twice — once picking the Jinja2 templates dir as
`help_dir` (`/sidecar/attune_gui/templates`, no `features.yaml`),
once picking the project root and getting auto-scaffolded junk
features. Both are correct error outputs from the executor, but the
picker UI gave no signal about which dirs were valid `.help/`
candidates.

Fix has three pieces:

1. **Server**: `/api/fs/browse` accepts `?annotate=help`. When set,
   each entry (and the current dir) gets `has_manifest: bool`
   indicating whether `features.yaml` is present. No effect on the
   default wire shape, so existing callers are unaffected.

2. **Schema**: `author.generate.help_dir` carries
   `"ui:browseHint": "help"`. Generic JSON-schema extension; future
   commands with similar semantics can reuse it.

3. **Picker**: when opened with a `browseHint`, calls the annotated
   endpoint and surfaces:
   - A status banner above the listing — green "✓ This directory
     contains features.yaml — safe to choose" or yellow "⚠ This
     directory has no features.yaml. Look for a child marked '✓
     manifest' or navigate up."
   - A green "✓ manifest" pill next to qualifying entries; the
     entry name itself goes bold for emphasis.

Verified end-to-end in the browser: opening `author.generate`,
clicking Browse on `.help/ path`, navigating up to `sidecar/`
shows the yellow warning + `.help/✓ manifest` highlighted while
`attune_gui/`, `attune_gui.egg-info/`, `tests/` stay
de-emphasized.

3 new fs tests cover the annotation contract (per-entry flag,
current-dir flag, default-path-omits-flag for backward compat).

Server suite: 345 → 348 passing. Ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(help): curate features.yaml + generate template-editor templates

Two adjacent things, both produced by exercising the new dashboard
Commands form end-to-end:

1. **`.help/features.yaml`** — adds a real `template-editor`
   feature that points at the actual editor source files (29 paths
   across `editor-frontend/src/*.ts`, `sidecar/attune_gui/routes/
   editor_*.py`, the editor session/corpora helpers, and the
   Jinja shell). The four auto-scaffolded entries beneath it
   (`attune_gui-entry`, `scripts`, `sidecar`, `ui`) are kept for
   back-compat and prefixed with a comment marking the curated
   `template-editor` entry as the canonical one to use.

2. **`.help/templates/template-editor/{concept,task,reference}.md`** —
   the templates `author.generate` rendered from those 29 source
   files. The concept template introduces the editor and lists every
   M1–M5 capability; the task template covers the open→edit→save
   loop; the reference template documents the full API surface.
   Frontmatter carries the feature/source_hash that the
   staleness/regen pipeline expects.

Also includes `sidecar/.help/templates/attune-gui/*` from an earlier
test run against the auto-scaffolded `attune-gui` feature in
`sidecar/.help/features.yaml`. That feature has no real source
files, so the content is generic rather than feature-specific —
keeping it for now as a reference example of what a thin manifest
produces, but the curated `template-editor` set above is the
useful artifact.

Generated by:
  author.generate(
    feature="template-editor",
    help_dir="/Users/patrickroebuck/attune-gui/.help",
    project_root="/Users/patrickroebuck/attune-gui",
  )

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
silversurfer562 added a commit that referenced this pull request May 8, 2026
…27)

Phase D4 of the architecture-realignment spec — final phase. Closes
findings #5 (private cross-route _get_pipeline import) and #7
(inconsistent error envelopes).

#5 — RagPipeline cache moved to attune_gui.services.rag_pipeline
with public ``pipeline_for(workspace)`` / ``invalidate(workspace)``.
routes.rag, routes.search, routes.living_docs, and the
_author_proxy invalidate path all import from the canonical owner.
routes.rag keeps backwards-compat aliases (_get_pipeline,
_PIPELINES, etc.) so existing in-tree code that referenced the old
private surface keeps working.

#7 — attune_gui.errors registers three handlers at app
construction: HTTPException (normalizes string + dict detail to
the envelope shape), RequestValidationError (renders 422s with a
``validation_error`` code and the original error list as a sibling
key), and bare Exception (catches anything else, logs the
traceback, returns the canonical 500 envelope without leaking the
exception message). Every ``/api/*`` response now renders through
one shape:

  4xx → {"detail": {"message": str, "code": str | null}}
  5xx → {"detail": {"message": "internal error",
                    "code": "internal_error"}}
  422 → {"detail": {"message": "Request validation failed.",
                    "code": "validation_error",
                    "errors": [...]}}

Routes raising HTTPException(detail="some string") get normalized
into the dict shape automatically — no per-route migration needed.
Routes already using detail={"code": ..., "message": ...} flow
through unchanged.

17 new tests (9 services + 8 envelope). Existing tests updated to
assert against detail["message"] instead of bare detail string.

Bumps to 0.7.0. Stop saying "internal_error" leaks an exception
detail to clients is a behavior change worth a minor bump even if
the shape itself is mostly compatible.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant