Tags: Zipstack/unstract
Tags
UN-4137 [FIX] Stream LLM completions under the hood so long generatio… …ns complete instead of timing out (#2294) * UN-4137 [FIX] Stream LLM completions under the hood so long generations complete instead of timing out and being replayed A non-streaming completion keeps the socket silent until the last token. On Anthropic, generations the console finishes in ~16 minutes never arrived: litellm.Timeout after 900 s (staging) and after 1800 s (production), then replayed up to 4x by the retry helper because Timeout is retryable, until the Celery time limit killed the task. - LLM.complete() streams (stream=True), collects the chunks and rebuilds the full response with litellm.stream_chunk_builder; callers unchanged. - collect_with_retry: retry only before the first content chunk; a drop after content started is raised immediately so a long generation is never replayed, and chunks from a failed attempt are discarded. - "Enable Streaming" checkbox on all LLM adapter forms, default on; adapters without a stored value stream too. Opt-out for endpoints that cannot stream. Read from raw adapter metadata, never sent to litellm. - Anthropic Timeout description now reflects per-chunk semantics. Verified locally on the reporting project: 12-minute Sonnet 4.6 generations (64k / 67k completion tokens) complete with no retry under the same 900 s / 3-retry settings that failed in staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4137 [FIX] Retry request errors raised while creating the stream collect_with_retry created the stream outside its protected block, so an error raised by fn() itself escaped on the first attempt. litellm's streaming completion() sends the HTTP request when called, which is exactly where a 429/5xx/connection error surfaces, so those requests would have failed where the non-streaming path retried them. Create the stream inside the try block; a failure there is a failed request and retries like before. Tests cover fn() raising before returning an iterable, both retryable and not, and the same through LLM.complete() with a RateLimitError from completion(). Addresses Greptile finding on PR #2294. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4137 [FIX] Keep mocked completions on the non-streaming path The e2e rig mocks the LLM through litellm's mock_response and counts calls with litellm's fixed mock usage (10/20/30), which litellm reports only on the non-streaming path; the streaming mock token-counts the real prompt instead. A mocked completion never touches the network, so streaming buys nothing there. Bypass streaming when a mock response is injected, restoring the rig's contract. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
UN-4126 [FIX] Stop a NUL in an extracted value from stranding the exe… …cutor RPC caller for an hour (#2289) * UN-4126 [FIX] Stop a NUL in an extracted value from stranding the executor RPC caller for an hour A finished executor task could fail to hand its result back, leaving the blocking caller to wait out the full EXECUTOR_RESULT_TIMEOUT (3600s) on work that had already succeeded, then error. Seen in prod us-central and reproduced in a dev namespace. `store_result` encodes the reply with `json.dumps` and inserts it as `%s::jsonb`. That cast makes Postgres *parse* the JSON, and `jsonb` stores strings as `text`, which cannot hold a NUL: psycopg2.errors.UntranslatableCharacter: unsupported Unicode escape sequence DETAIL: \u0000 cannot be converted to text. CONTEXT: JSON data, line 1: ..."output": {"invoice_number": "POZFBBOK\u0000... LLMWhisperer's `native_text` mode returns a PDF's embedded text layer verbatim, NUL bytes included; the byte travels through the prompt into the extracted output. The consumer then logs-and-acks (deliberately, to avoid an expensive re-run), so a write that never lands is a reply that never comes. This is PG-only and did not exist before. Celery's result backend stored the reply object in a `PickleType` column (bytea), where a NUL is just a byte; the payload shape never changed, only the storage medium. The code has been reachable since #2108 (Jul 23) but became reachable for *all* traffic when UN-4046 made `get_executor_dispatcher()` return the PG dispatcher unconditionally. Adds `unstract.core.jsonb` as the single place that knows what `jsonb` refuses (NUL, lone surrogates, NaN/Infinity) and points all three PG-queue jsonb writers at it. They previously carried three different partial defences — the barrier guarded NUL reactively, the producer guarded NaN, the result backend guarded neither — which is why an existing regression test for this exact hazard (`test_nul_byte_result_tears_down_barrier`) could not catch it. Strings are repaired rather than rejected: the work is done and paid for, and a stray control byte is not a reason to discard an extraction. Numbers are not — `NaN` has no correct jsonb spelling and coercing it would corrupt a value, so `allow_nan=False` is enforced and the caller decides. `store_result` now always writes a row. If the payload is still unstorable it degrades to a `failed` row carrying a diagnostic message, so the caller fails in seconds instead of hanging. That is the property that made this invisible for two weeks, and it is now the one under test. Behaviour change worth review: `allow_nan=False` moves NaN/Infinity in a barrier header result from the DataError path to the encode path. That class used to reach the barrier teardown, so the teardown is extended to the encode-failure branch — without it a NaN would newly hang the barrier to its ~6h expiry. No migration: `pg_task_result.result` stays `jsonb`. Tests: new `unstract/core/tests/test_jsonb.py` (15); three DB-backed tests in `test_pg_result_backend.py` including a literal reproduction of the production payload; `test_pg_barrier.py`'s NUL test inverted to assert repair, plus a new teardown test for the unencodable case. Full workers suite 1485 passed against a real Postgres. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Close the same jsonb gap on the enqueue path, which loses a callback instead of hanging An audit of every raw-SQL jsonb writer in the PG queue found three more sites on the plain `json.dumps` path. One of them carries the SAME payload as the bug this branch already fixes. `consumer._handle` self-chains a continuation with `prepend=eager.result` — the executor result dict, byte-for-byte the object `store_result` stores. It reaches `PgQueueClient._insert_message`, which encoded with a plain `json.dumps` and inserted as `%s::jsonb`. A NUL in an extracted value therefore fails the INSERT on this branch too. The symptom differs, which is why it was not visible alongside the original report. `_chain_continuation` is documented "never raises" (a failure must not wedge the executor message's ack), so the enqueue failure is swallowed and the run falls back to `on_error`. That fallback prepends only a `task_id` string, so it succeeds — meaning the user is shown a FAILURE for work that completed, with the LLM spend already made. No hang, no error in the caller's logs, just a wrong terminal state. Sites changed: - `pg_queue/client.py::_insert_message` — the workers' enqueue. Kept without a `default=` so the TypeError-on-UUID contract that `consumer._json_safe` compensates for is unchanged. - `pg_queue/consumer.py::_json_safe` — the prepend coercion; the same pass now strips what jsonb refuses instead of only coercing UUID/datetime. - `pg_queue/pg_scheduler.py` (x2) — scheduler-built payloads. Lower risk (they carry schedule rows, not document content) but the same shared rule, so the remaining `json.dumps` in the tree is gone. Audited and deliberately NOT changed: `pg_barrier.py`'s `'[]'::jsonb` literals (no user data) and `liveness.py`'s `json.dumps(...).encode()` (an HTTP body, not SQL). Test: `test_send_repairs_a_nul_instead_of_failing_the_enqueue` in `test_pg_queue_client.py` — real Postgres, sends the executor-result shape with a NUL and asserts the message lands and reads back repaired. Full workers suite 1486 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Keep valid surrogate pairs — strip only the halves jsonb rejects Greptile P1 on #2289, and it is correct: `JSONB_UNSAFE_RE` was the character class `[NUL + U+D800-U+DFFF]`, which matches BOTH halves of a well-formed surrogate pair. An emoji in an extracted result reaches Python as a high+low pair, so the sanitiser deleted it outright — silent data loss, in the module written to prevent silent data loss. Verified against a live Postgres rather than reasoned: valid pair (U+1F600) -> ACCEPTED {'a': 'GRINNING FACE'} lone high (U+D800) -> REJECTED invalid input syntax for type json lone low (U+DC00) -> REJECTED invalid input syntax for type json NUL (U+0000) -> REJECTED unsupported Unicode escape sequence So a pair is storable and must be preserved; only unpaired halves are unsafe. The pattern now matches NUL, a high surrogate with no low after it, and a low surrogate with no high before it. `re.sub` evaluates against the original string, so a deletion cannot fuse two neighbours into a spurious "pair". Tests: four new cases in test_jsonb.py (pair preserved, lone low stripped, only the unpaired half stripped, pair intact in the encoded output) and a DB-backed round-trip in test_pg_result_backend.py storing an emoji through the real result store. Full workers suite 1487 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Close the three High findings from the standardized review All three reproduced independently before fixing; none was taken on trust. 1. `default=` hook output bypassed sanitisation (jsonb.py) `sanitize_for_jsonb` runs before `json.dumps`, so a string the hook manufactures at encode time was never inspected — and `str` on an exception carrying document text is exactly how a NUL gets there. The module's stated guarantee was false at every call site passing a hook: a NUL-bearing object coerced via `default=str` re-emitted the escape jsonb refuses. The hook is now wrapped rather than passed through. No call site changes. 2. `RecursionError` escaped both degradation seams (jsonb.py) The Python pre-walk sits in front of a C encoder that has its own cycle check, so a circular reference raised `RecursionError` where `json.dumps` raised `ValueError`, and nesting past ~1000 newly failed. `RecursionError` subclasses `RuntimeError`, so `except (TypeError, ValueError)` missed it at both seams — re-creating the exact strand this PR removes, on a new input class. Now re-raised as `ValueError` so the documented contract and every downstream seam keep working. 3. "A row is always written" was false (result_backend.py) `ProgramLimitExceeded` (SQLSTATE 54, "string too long") subclasses `OperationalError`, so it was absent from the `DataError` net and present in `CONN_DEAD_ERRORS`: an oversized result was misread as a dead connection, pointlessly retried, then escaped — no row, caller stranded for the full 3600s. Signalling was also skippable, and two inserts were unguarded. Departed from the review's suggested fix here. It proposed `except Exception`; that breaks three existing tests encoding a deliberate contract (infrastructure errors propagate) and, worse, would record a database outage as "payload unstorable" — hiding an outage behind a content error. Content rejections are a knowable, closed family, so they are enumerated in `_PAYLOAD_REJECTED_ERRORS` instead. Signalling moved into a `finally`, and the degraded insert is now itself best-effort. Also from the review (Medium): `status="failed"` now has a third meaning — completed-but-undeliverable. Recovery logic that retries every `failed` reply key would re-run an executor task that already finished, a second full LLM spend. Documented on both the backend module and `PgTaskResult`. Testing, addressing the review's strongest finding — every real-Postgres assertion for this fix sat in `integration-workers`, which is `optional: true` and cannot turn CI red. The strand-prevention contract now has DB-free tests in the gating `unit-workers` lane (`TestStoreResultNeverStrands`), using a fake cursor: oversized result still records a failed row, unencodable result still records a failed row, and the waiter is signalled even when every write fails. Plus three encoder tests in the gating `unit-core` lane. Full workers suite 1490 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Close the four open High findings, and the barrier's sibling of the bug this branch just fixed Two of these are regressions introduced by the previous commit. 1. The `finally` test asserted nothing (test_pg_result_backend.py) `test_waiter_is_signalled_even_when_every_write_fails` injected `ProgramLimitExceeded` — a member of the rejected family — so `_write_outcome` caught it, `_insert_degraded` swallowed the second failure, and the method returned NORMALLY. The `finally` was never the reason the signal fired, so deleting it left the suite green: a future refactor moving the call back to a trailing statement would have silently restored the strand. Split in two. The raising path now injects an error OUTSIDE the rejected family so it propagates and only the `finally` can signal; the swallowing path keeps the original scenario. Mutation-checked: removing the `finally` now fails the first and passes the second, which is the point. 2. `Raises:` named ValueError for a TypeError case (jsonb.py) The previous commit rewrote this block and kept the false clause, describing one condition with two classes. `json.dumps` raises `TypeError` for an unserialisable type with or without a `default`; `ValueError` is only NaN/Infinity and the recursion conversion. 3. The shared status enum was not extended (data_models.py) `PgTaskStatus` exists so the workers writer and backend reader agree on this vocabulary — its docstring says exactly that. Both consumers' docstrings were updated last commit; the canonical definition still read "task raised", so a maintainer reading the source of truth got the contract this branch invalidated and would build the retry that double-spends. Documented there, including why there is no third status value (it would break every existing reader) and that the discriminator is therefore the error text. 4. Two of four jsonb sinks had no gating coverage The previous commit closed this for `result_backend` and `jsonb`; `client.py` and `pg_barrier.py` were still verified only by `integration-workers`, which is `optional: true`. Both now have DB-free tests in gating lanes. 5. The barrier carried the same DataError-only net (pg_barrier.py) This branch's whole argument is that `ProgramLimitExceeded` subclasses `OperationalError` and slips a `DataError`-only catch. `result_backend` was widened; its sibling was not — an oversized header result was misclassified as a dead connection, the same oversized UPDATE re-sent, then it escaped and the barrier hung to `expires_at` (~6h) with no actionable log. The classification tuple moves to `pg_queue/connection.py` beside `CONN_DEAD_ERRORS`, for the reason that module already gives for hoisting the other one: both sites classify on it and must not drift. The comment records the trap explicitly — `ProgramLimitExceeded` is absent from `DataError` AND present in `CONN_DEAD_ERRORS`, so payload rejection must be tested first. Full workers suite 1493 passed, 1 skipped; core 22 passed. Deliberately still open, and recorded as deferred rather than waived: the sibling message asymmetry (consumer.py:660), the stale `json.dumps` docstring (consumer.py:751), the producer's per-field sanitisation, key-collision and mutation-logging in the sanitiser, and the degraded row's ON CONFLICT semantics. The last two are design decisions that want an owner's call, not a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Stop the barrier decrement releasing the reaper's recovery handle Regression introduced by this branch, found by the standardized review and confirmed independently. It is worse than the bug it was trying to avoid. The first commit extended the barrier teardown to the encode-failure branch, reasoning that `allow_nan=False` moved NaN/Infinity onto that path so the teardown should follow it. That reasoning checked only which exception reached the branch — never whether tearing down there was correct at all. It is not. `run_batch_with_barrier` is the only caller, and it deliberately implements the opposite ordering: if _mark_execution_error_on_abort(barrier_context, reason=reason): _abort_barrier_in_body(...) # confirmed terminal -> release else: # Mark unconfirmed (backend down / no org): leave the barrier row so # the reaper marks it ERROR and reclaims it at expiry. Do NOT erase the # handle — that's the strand this ticket fixes. `_barrier_pg_decrement` called `_delete_barrier` and then re-raised, so by the time that `else` runs the row is already gone. The execution stays EXECUTING with no `pg_barrier_state` row for the reaper's sweep to find — permanently stranded, not hung-to-expiry — and stale EXECUTING rows also make the workflow look busy to later runs (`SourceConnector._get_active_workflow_executions`). Both `_delete_barrier` calls on the raising paths are removed; the failures now propagate and the caller's mark-then-release ordering governs. The two calls that remain in the function are on non-raising paths (expiry/replay at `remaining < 0`, and cleanup after the callback fires at `remaining == 0`), so they cannot pre-empt the caller's handler — verified by AST walk. Scope note: this removes the pre-existing teardown on the DB-rejection branch as well, not only the one this branch added. The two were the same defect; leaving one would keep the strand reachable for oversized payloads. Tests: both tests that pinned the old behaviour now assert the new contract — `test_unencodable_result_raises_and_leaves_the_row_for_the_caller` (real PG, asserts the row survives) and `test_oversized_result_propagates_without_ releasing_the_handle` plus a new encode-side twin (both gating, assert `_delete_barrier` is never called). Full workers suite 1494 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Gate the result-backend encoder, and stop the error-only path stranding Four findings from the second standardized review, all in the result backend. 1. The encoder swap had ZERO gating coverage. Every test that proved `dumps_for_jsonb` was in use ran against real Postgres, and `conftest.py` marks those `integration`, which routes them to `integration-workers` — `optional: true`, so it cannot fail CI. Reverting the call to plain `json.dumps` left the gating lane green at `15 passed, 17 deselected`. Three mock-level tests now assert on the SQL parameter itself: `test_result_payload_is_sanitised_before_the_jsonb_cast`, `test_error_text_is_sanitised_before_the_insert`, `test_rejected_error_text_also_degrades_to_a_row`. With the sanitisers reverted the lane now reports `2 failed, 16 passed` — mutation-verified, not assumed. 2. The `result is None` branch of `_write_outcome` could still strand. The completed branch catches `_PAYLOAD_REJECTED_ERRORS` and writes a degraded row; the failure branch did not, so an error string the database refused (a NUL from a traceback quoting extracted text, or one over the field limit) propagated with no row written at all — the exact strand this ticket exists to close, reachable from the error path instead of the success path. It now degrades the same way. 3. The surrogate round-trip test could not fail. `chr(0x1F600)` is a single code point on a wide build, not a surrogate pair, so the naive `[\x00\ud800-\udfff]` class the previous commit removed would have left it untouched and the test would have passed against the bug it was written to catch. It now builds the pair explicitly — `chr(0xD83D) + chr(0xDE00)` — and asserts Postgres recombines it to `chr(0x1F600)`. 4. `PgResultBackend.PAYLOAD_UNSTORABLE_ERROR` in the `PgTaskStatus.FAILED` docstring was a dotted path that does not resolve; the constant is a module attribute, not a class one. Corrected to `queue_backend.pg_queue.result_backend.PAYLOAD_UNSTORABLE_ERROR`. Tests: `test_pg_result_backend.py` 35 passed. Full workers suite 1497 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Record the encoder's measured cost, and why the fast path was rejected Regression sweep before merge. The sanitising walk rebuilds every container and string on the way to the database, so it is the one place on this branch that could cost something on the hot path. Measured rather than assumed: ~11x a plain `json.dumps` on a 165 KiB result (0.38 ms -> 4.2 ms) and ~16x on 2 MB (3.1 ms -> 49 ms). Accepted. One encode per stored result, against a task that took seconds, at a measured platform ceiling of ~4-6 executions/s spread across the executor fleet. The obvious optimisation was built and thrown away, which is the part worth recording: encode first, scan the OUTPUT for the escapes jsonb refuses, and walk only on a hit. It is correct and it is fast, but the walk is recursive and the C encoder is not, so it made a deeply nested payload succeed or fail depending on whether it also contained a NUL -- and it broke `test_excessive_nesting_raises_valueerror`, which pins exactly that contract. Trading a consistent contract for milliseconds nobody is waiting on is the wrong way round, so the note explains the cost instead of the code chasing it. No production code changes in this commit. Tests: unstract/core 22 passed. Full workers suite 1497 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Stop an oversized payload being re-sent as if the connection died Greptile's remaining P2 on this PR, and it is right. The previous commit documented the trap and left it in place. `ProgramLimitExceeded` ("string too long", SQLSTATE 54) subclasses `OperationalError`, so it is a member of BOTH `PAYLOAD_REJECTED_ERRORS` and `CONN_DEAD_ERRORS`. Every retry site classified with a bare `isinstance(exc, CONN_DEAD_ERRORS)`, which answers "dead connection" for a payload the server merely refused: the handle is condemned and closed, a reconnect happens, and the write is re-sent once before the caller's payload-rejection handler finally sees it. It can never land, and the payload is by the nature of this error very large — so the cost is a pointless reconnect plus a second large write, on the path that is already degrading. The order is no longer left to each call site. `is_connection_dead` in connection.py is now the single place that decides, testing the payload family first, and the four retry sites ask it instead of testing membership themselves: `PgResultBackend._cursor` / `_store_with_reconnect`, `PgQueueClient._cursor` / `send` / `delete`, and the barrier's `_recover_after_error` / `_run_with_reconnect`. A rejected payload leaves the connection perfectly usable, so it also stops being closed. Tests: `TestPayloadRejectionIsNotAConnectionDeath` covers the predicate directly (including an assertion that ProgramLimitExceeded really is an OperationalError, so the test fails if psycopg2 ever changes the hierarchy this reasoning rests on) and the behaviour end to end — one rejected INSERT then the degraded row, no reconnect, connection not closed. Mutation-verified: restoring the bare `isinstance` turns the gating lane red (2 failed, 20 passed). Full workers suite 1501 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4126 [FIX] Stop a genuinely failed task being recorded as completed-but-unstorable Praveen's review finding on #2289, and it is correct. `_write_outcome`'s `result is None` branch runs only when the task RAISED. When the database refused its error text, that branch called `_insert_degraded`, which wrote `PAYLOAD_UNSTORABLE_ERROR` -- and this PR's own contract makes that exact string the signal for "the task completed; retrying this reply key is a second full LLM spend". So the one case where a retry is not merely safe but correct was being labelled as the one case where it must not happen, and the task's real error message was dropped on top. `ERROR_TEXT_UNSTORABLE` is the twin for the failure channel, saying plainly that the task did NOT complete and the reply key is safe to retry. `_insert_degraded` now takes the text as a keyword argument, defaulting to the completed-branch constant since that is the common path. Two constants rather than one parametrised string, so the difference is visible at both call sites. The module docstring and the `PgTaskResult.status` comment previously described `failed`'s third meaning as a single case; both now describe two, and say explicitly that seeing `failed` is not enough -- recovery logic must match the error text, because the two point in opposite directions. Why the existing test missed it: `test_rejected_error_text_also_degrades_to_a_row` asserted only that a second row was written with status `failed`, never what it said. It now asserts the text, and a twin test pins the completed branch from the other side; both assert the two constants are not each other, so swapping them fails rather than silently passing. Mutation-verified -- restoring the original call turns the gating lane red. Full workers suite 1502 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
UN-4124 [FIX] Implement antd's expandable API on the shared DataTable… … so nested cell values expand again (#2288) * UN-4124 [FIX] Implement antd's expandable on the shared DataTable The Ant Design removal (#1683 / #2212) replaced `<Table>` with the shared DataTable, which never implemented antd's `expandable` API. Undeclared, the whole object fell into `...props` and was spread onto the wrapper <div>, where React ignores it — so `expandedRowRender` was never called and the table rendered as if the prop had not been passed, with no console error. HITL's review editor is the visible casualty: an array or object inside a table cell shows a truncated JSON blob with an expand button beside it, and clicking that button did nothing at all, leaving nested values unreadable in Table view. Support `expandedRowRender`, controlled `expandedRowKeys` and uncontrolled `defaultExpandedRowKeys`, `rowExpandable`, `showExpandColumn`, `expandIcon`, `expandedRowClassName`, `onExpand` and `onExpandedRowsChange`. The panel is a sibling <tr> spanning every column, since a row may only contain cells. Keys are compared as strings because that is what TanStack's `getRowId` (and so `row.id`) produces: a call-site numbering its rows `key: index` passes numbers, and `[0].includes("0")` is false — a mismatch that would have hidden every expansion on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4124 [FIX] Report expansion keys in the caller's own type Review feedback on #2288. `onExpandedRowsChange` built its argument from the internal `expandedKeys` set, which is normalized with `.map(String)` so it can match TanStack's `row.id`. That normalization leaked out: a controlled caller that passed `[1]` was handed back `["1"]`, and a parent testing `includes(1)` against it would never match. Normalization is now strictly internal. The reported array is built from the caller's own keys — `controlledExpandedKeys ?? ownExpandedKeys` — appending `originalRowKey()`, which reverses `getRowId`'s stringification by reading `rowKey` off the record directly. Collapse compares with `String(k) !== key` so it can filter a normalized id out of an un-normalized list without rewriting the survivors' types. Expansion itself is unaffected: `expandedKeys` is still a string Set, so matching behaves exactly as before. `defaultExpandedRowKeys` no longer stringifies on the way into state, since the memo normalizes anyway. Both directions are covered and mutation-checked: stringifying the appended key fails the expand test, and filtering the normalized set fails the collapse test. Also addresses SonarCloud S9020 — `waitFor` + `getByText` becomes `findByText` in the nested-panel test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
UN-4016 [FEAT] Publish the API deployment listing in the OpenAPI spec (… …#2278) * UN-4016 [FEAT] Publish the API deployment listing in the OpenAPI spec A platform API key already reaches the organisation-scoped listing, so a holder can discover what is deployed without being told. Nothing published it, so no generated client could call it. The endpoint is unchanged. What is added is the annotation, the mount the spec is generated against, and the organisation segment: the router never sees that segment -- OrganizationMiddleware strips it before anything is routed -- so a spec generated from the URLconf described a path no caller sends. It is restored from the same setting that decides which routes are served without it, so the two cannot disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Trim the comments to the reason, not the account of it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Drop the critical-path registration for the listing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Make the listing safe to page, filter and generate from Publishing the endpoint hands a caller three things the frontend never exercised, and each of them was wrong: - Paging. Every deployment that has never run tied on the ordering, and each page is its own query, so a client walking the pages could see a row twice and miss another. The ordering now ends with the primary key. - The workflow filter. A malformed value reached the database and raised past the handler that turns a bad request into a 400. - Page size. A caller may ask for a thousand rows, and the listing cost three queries per row; the run count and last run time now come from the list query's own annotations. The published component also marked the fields the description tells a caller to read as optional, because the model defaults them, so every generated client would have typed them nullable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Keep the published lengths, and count a never-run row as annotated A deployment that has never run annotates to NULL, and reading the value rather than its presence sent every such row back to the database, which is the query pattern the annotation was added to remove. DRF drops max_length from a read-only field, so restating the response fields cost the generated client the length limits the model enforces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [CHORE] Note the filtering that the spec has to restate Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FEAT] Give every operation the one-line name a command list shows A generated client headlines its method with the summary and a CLI lists each command by it. With none, that line falls back to the operation id. The descriptions now read for the caller who meets them as terminal help: the credential first, then the behaviour that surprises. What each one promises about the server is pinned, since prose is the only place a caller learns it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Name the credential these operations really take Execution accepts a global API deployment key when the deployment's own key does not authorize the call, so describing only the latter would have told a generated client that a working credential cannot be used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Say only what a caller cannot read off the schema The listing description inventoried the fields of its own response component, which the schema already carries with types attached, and which drifts the day a field is added. The rest was rationale written for a reviewer: why a platform key cannot be widened, why a deployment key authenticates elsewhere, how a client that raises on non-2xx should cope, and an organisation-qualified alias that is deliberately unpublished and so cannot be called from this spec at all. Behaviour stays whole. Every promise a caller acts on -- the file sources and their cap, the queued return under `timeout: -1`, the one-shot read and its 406, 422 as the normal reply while running, the absent deployment key -- is still stated, and still pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Stop the listing narrowing which key executes Saying execution needs "that key" pointed back at the deployment's own key and shut out the global API deployment key that also runs it. The possessive was the whole error: which keys qualify is stated once, on the security scheme and the execute operation, and repeating the list here would only give it a second place to go stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ * UN-4016 [FIX] Let the listing name its own published mount The gate matches a route, not the mount it hangs off, so the listing has to appear as itself rather than ride the tenant prefix. Naming it keeps the narrower check intact: an API_DEPLOYMENT_PATH_PREFIX pointed somewhere else under the tenant mount is still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
UN-4046 [DOCS] Document the PG queue transport in the dashboard-metri… …cs README (#2280) * docs: document the PG queue transport in the dashboard-metrics README The README described a Celery-only architecture and told readers to start the cron with `celery -A backend beat` and `celery -A backend worker -Q dashboard_metric_events`. Neither process is defined in the default compose stack any more: UN-3796 added the PG path and UN-4046 made it the default, deleting celery-beat and worker-metrics and adding worker-pg-metrics. Anyone onboarding from this file started a worker the fleet no longer runs, and had no way to find out why nothing fired. Add a Transport chapter covering the dual-written schedule rows, the full PG path from the leader-elected tick through worker-pg-metrics and the internal API to tasks.py, why the proxy hop exists (no Django in the workers image), the two env gates and their defaults, and the failure modes specific to this transport — fire-and-forget with MAX_ATTEMPTS=1, autoretry_for being inert on the PG path, and the silent no-firer state. Rename "Celery Tasks" to "Scheduled Tasks", since the task bodies are shared and only the transport differs. Keep the Celery path documented throughout as the rollback target rather than deleting it. Update the setup and troubleshooting steps to match: step 5 now prints pg_owned and cron_string from PgPeriodicTask alongside the Beat rows, which is the check that actually identifies which scheduler owns the cron. Docs only, no behaviour change. * docs: name worker-pg-reaper as the firer, and reconcile the retry claim Self-review found the new Setup section reproduced the exact failure the change was written to prevent. It listed only the metrics consumer as the process to start, and stated there was "no separate Beat-equivalent process to start". That is true of compose, where worker-pg-reaper is already declared, but false for the host-runner flow the same paragraph tells the reader to use: the periodic tick runs inside the reaper (reaper.py:1310 calls dispatch_due_periodic_tasks), so starting only `run-worker.sh pg-metrics` leaves the schedule with no firer — silently. Start both processes in Quick Commands and Setup step 4, and say why both are needed. Name worker-pg-reaper explicitly in the diagram and in the no-firer failure mode. "The PG scheduler" was ambiguous against the worker-pg-scheduler service named forty lines earlier, which is the pipeline-task consumer and fires no periodics — a reader debugging the silent state would have checked the wrong service and found it healthy. Also: - Qualify the "Celery tasks have max_retries=3" line by transport. It contradicted the new failure-modes section, which is correct for the live path (MAX_ATTEMPTS=1, autoretry_for inert on internal HTTP). - Rename the duplicated "Task Name" columns to Python Function / Registered Task Name. - Drop the suite count that disagreed with the tree printed below it. - Fix the TOC's broken #frontend-components anchor, whose heading is "UI Data Flow — What Shows Where". Pre-existing, but in a block this change already rewrites, and it was the file's only MD051. * docs: correct the drift claim, the --migrate gate, and the rollback runbook Second self-review pass, all verified against source. The dual-write claim was wrong for most of the rows. It said every schedule is written "from a single spec by the same migration so the two cannot drift", citing 0006. That holds for the two aggregation rows and the reconciliation row only. The three original rows come from two separate migrations — 0002 for Beat and its PG twin 0004 — and 0006's own docstring says so ("cannot drift the way 0002 and 0004 can"), as does the drift-guard test that exists for it. Replace the sentence with a table splitting the two cases and point at tests/test_pg_periodic_task_declarations.py. Convergence does not run on "every backend start". The whole block in entrypoint.sh is gated on --migrate, which compose passes but a deployment running migrations as a separate job does not. The payoff clause was "ownership is a values change rather than a remembered procedure", which is exactly the belief that breaks there. The rollback runbook could produce the outage it warns about. Releasing the metrics rows back to Beat needs --periodics too, so PG_SCHEDULER_ADOPT_PERIODICS must stay true while PG_SCHEDULER_ENABLED goes false. Unsetting both — the intuitive "roll it all back" — leaves the rows pg_owned with Beat disabled, i.e. no firer. Also corrected: - "logs at ERROR rather than raising" conflated two cases. A non-200 does raise; only a 200 reporting per-org errors is logged instead. - MAX_ATTEMPTS=1 is one service's env in compose, not a property of the transport; the consumer default is 5. Attribute it, and mention the httpx connection-establishment retries. - The autoretry_for bullet had the right conclusion via the wrong mechanism. The decorator IS in the call path; retry() re-raises when a task is called directly instead of dispatched by a worker. - Note the endpoint path is relative to INTERNAL_API_BASE_URL, which ends /internal, so the diagram can't be pasted into curl as-is. - --periodics covers every mirrored non-pipeline periodic, not just these. - Stop quoting exact liveness numbers as fact: they are ${VAR:-default} overrides and k8s takes its values from a chart in another repo.
UN-3393 [FEAT] Wire AuditSerializer subclasses through SanitizedSeria… …lizerMixin (#1966) * UN-3393 [FEAT] Switch AuditSerializer to SanitizedSerializerMixin base Builds on the foundation PR by routing every `AuditSerializer` subclass through `SanitizedSerializerMixin`. ~18 write-path serializers (`WorkflowSerializer`, `CustomToolSerializer`, `APIDeploymentSerializer`, `APIKeySerializer`, `ConnectorInstanceSerializer`, `BaseAdapterSerializer`, `PlatformKeySerializer`, `PlatformApiKey{Create,Update}Serializer`, `PipelineSerializer`, `ToolInstanceSerializer`, `ToolStudioPromptSerializer`, `PromptStudioOutputSerializer`, `ProfileManagerSerializer`, `IndexManagerSerializer`, `PromptStudioRegistry{,Info}Serializer`, `PromptStudioDocumentManagerSerializer`) now auto-reject HTML/JS-shaped input on every writable `CharField` / `TextField`. - `backend/backend/serializers.py`: `AuditSerializer` now inherits `utils.serializer.ModelSerializer` (the pre-mixed variant) instead of `rest_framework.serializers.ModelSerializer`. `create` / `update` semantics unchanged. - `prompt_studio/prompt_studio_v2.ToolStudioPromptSerializer`: declares `Meta.html_safe_fields = ("prompt", "assert_prompt", "assertion_failure_prompt", "output")`. LLM prompt text legitimately contains XML/HTML-like markup (e.g. `<context>`, `<thinking>`); LLM `output` may include anything the model produced. - `prompt_studio/prompt_studio_core_v2.CustomToolSerializer`: declares `Meta.html_safe_fields = ("summarize_prompt", "preamble", "postamble", "output")`. Tool-level LLM context fields and stored LLM output. - Removes redundant manual `validate_description(self, value)` methods in `api_v2.APIDeploymentSerializer`, `workflow_v2.WorkflowSerializer`, and `prompt_studio_core_v2.CustomToolSerializer`. The mixin now covers these via the `AuditSerializer` base. `validate_<name>` methods that use `validate_name_field` are retained because they also strip whitespace and reject empty values — behaviour the mixin doesn't duplicate. - Imports of `validate_no_html_tags` are dropped from the three files whose `validate_description` methods were removed. Files that inherit DRF base classes directly (without going through `AuditSerializer`) are tracked as a follow-up sweep. The AuditSerializer path already covers the highest-value write-path entities. Test coverage: `cd backend && uv run pytest utils/tests/ -q` → 85 passed. Full Django check requires cloud-deps; PR CI exercises the full suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * UN-3393 [FEAT] Cover Tag and Notification serializers via the mixin Extends PR2 coverage to two user-write-path serializers that don't go through `AuditSerializer`: - `tags.TagSerializer` (user-create with `name` + `description`) now inherits `utils.serializer.ModelSerializer`. Sister `TagParamsSerializer` is a query-param parser with strict regex validation; left as-is. - `notification_v2.NotificationSerializer` now inherits `utils.serializer.ModelSerializer`. The model's `name`, `authorization_key`, `authorization_header`, and `url` (URLField is a CharField subclass) are auto-sanitized. `notification_type`, `authorization_type`, `platform` are ChoiceField in the serializer, not CharField; the mixin skips them. The existing manual `validate_name` keeps its uniqueness + strip-whitespace logic; the mixin's HTML check runs alongside as redundant defence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * UN-3393 [FIX] Opt PromptStudioOutputSerializer out of html_safe_fields Greptile P1 review comment on PR #1966. `PromptStudioOutputManager` stores raw LLM responses (`output`: CharField) and document chunks (`context`: TextField) that routinely contain <thinking>, <context>, and other XML-like tags. The serializer is mounted on a ModelViewSet with no class-level HTTP-method restriction, so write paths through DRF admin / browsable API / any future write endpoint would incorrectly reject legitimate LLM output without this opt-out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * UN-3393 [FEAT] Inline field errors on prompt card; humanise error labels - Mixin now emits "Prompt key …" instead of "prompt_key …" by using `field.label` or a title-cased fallback when invoking the validator. - DocumentParser re-throws DRF field-keyed validation errors so the prompt card can render them inline instead of showing only a transient toast. Non-field errors continue to surface via the existing toast. - PromptCard tracks per-field error state, keeps the typed value so users can edit-and-fix in place, and clears the error on the next edit attempt for the same field. - EditableText accepts an `error` prop, applies Ant `status="error"` on the input, and renders the message in a danger Text node below. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(prompt-studio): tighten html_safe_fields comments Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * UN-3393 [FIX] Make inline field errors opt-in so no rejection is silent handleChangePromptCard decided that a field-keyed validation error would be rendered inline purely from the error's shape, and skipped the global alert on that basis. Two callers made that wrong: - NotesCard has no inline renderer, so a rejected note title reverted with no feedback at all where it previously showed a toast. - PromptCard rendered only prompt_key, so a rejection on any other attr (postprocessing_webhook_url, enforce_type, non_field_errors) produced no inline error, no rollback and no alert. The caller now passes an onFieldError callback and returns whether it can render the errors; anything it declines falls back to the alert. PromptCard gates on RENDERABLE_FIELD_ERRORS and rolls back when nothing was rendered. handleChangePromptCard re-throws in both paths so callers still see the failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbK3GDtfcExzb6kceRAdhu * UN-3393 [FIX] Propagate prompt update failures so callers can roll back Header keeps its own copy of the webhook and toggle values, and its rollback handlers only ran if handleChange rejected. Call sites with no local state to restore opt out explicitly. * UN-3393 [FIX] Use a rejected value in the humanised-label test The fixture relied on a plain h1 tag, which the narrowed sanitizer allows. * UN-3393 [FIX] Keep the prompt card's state on the stored row when an edit is rejected An inline field error used to suppress the rollback of promptDetailsState, so a rejected prompt_key stayed in the card's copy of the row while the database kept the old one. Outputs, highlight and confidence data are keyed by prompt_key in single-pass extract and Simple Prompt Studio, so they stopped resolving for that prompt until the card unmounted. The rollback is now unconditional and EditableText keeps the typed text on screen while its inline error is showing, which is the part the user needs to correct in place. Each attempt also carries a per-field generation, so a rejection that lands after a newer attempt was accepted no longer paints a permanent inline error over a value the server took, and a success clears the field's error. * UN-3393 [FIX] Stop a superseded edit from alerting over an accepted value A rejected attempt that a newer one has already replaced was still routed to the global "Failed to update" alert, contradicting the value on screen. The callback now claims every rejection it deliberately stays silent about, so only the attempt that owns the field reports its outcome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbK3GDtfcExzb6kceRAdhu --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UN-3569 [FIX] Pass enable_header_mapping through agentic_params (#2268) * UN-3569 [FIX] Pass enable_header_mapping through agentic_params The cloud backend stores enable_header_mapping in tool_metadata["agentic_table_settings"], but structure_tool_task.py never forwarded it to the executor. The executor already reads params.get("enable_header_mapping", False), so this one-line addition closes the gap for API/ETL/workflow runs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: harini-venkataraman <115449948+harini-venkataraman@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
UN-4071 [FIX] Restore the Plan column on the LLM Whisperer API Keys p… …age (#2270) * UN-4071 fix: resolve antd's nested dataIndex in the shadcn DataTable The LLM Whisperer API Keys table declares its Plan column with antd's documented nested-path form, `dataIndex: ["product", "name"]`, which real antd resolved to `record.product.name`. The shadcn adapter that replaced antd's Table only ever did a flat, single-key lookup, so it indexed the record with the array itself and JavaScript stringified that to the property name "product,name" — undefined for every row. The column declares no `render`, so the undefined went straight to the cell: no error, just a blank column. Resolve a path `dataIndex` by walking it, in both the cell lookup and the TanStack accessor. String `dataIndex` keeps `accessorKey` so TanStack's dotted-string deep access is unchanged. The `id` derivation is deliberately left alone: it stringifies an array to "product,name", the same spelling `columnKey` and `toSorterInfo` use, so normalising it in one place only would break sorter/filter matching for a nested column. This is the only nested dataIndex in either repo today, which is why neither the build nor the existing 74 DataTable tests caught it. * UN-4071 fix: correct the accessor comment and pin the id invariant Self-review findings on the previous commit. The inline comment claimed "nothing reads row.getValue today", and that was wrong: TanStack defaults every column to sortUndefined: 1, and getSortedRowModel calls rowA.getValue() to apply it BEFORE consulting sortingFn. So the accessor half is live behaviour, not deferred correctness. Checked what that actually costs. A nested column with a sorter now reorders undefined-valued rows to the end -- identically to how an equivalent string column already does; verified both by probe, same order in and out. So the quirk is pre-existing and cross-cutting rather than introduced here, and no nested column declares a sorter today. The comment now says that instead of denying it. Also documented the id/columnKey/toSorterInfo three-way agreement at the line it constrains. It was argued only in a commit message, and "normalise this stringified array" is exactly the tidy-up a later reader would attempt -- which would silently stop a nested column reporting its sort. Tests: assert the missing-segment cells are actually EMPTY rather than just present; add a deeper path with an array-index segment; add the keyless-nested onChange case that guards the id invariant above. Both new tests were mutation-checked -- each fails against exactly the mutant it describes and no other. Dropped the fleet-wide "only nested dataIndex in either repo" claim, which a leaf test file cannot verify. 598 tests pass, build clean.
[P0-P4] Remove Ant Design from the OSS frontend (shadcn/ui + Midnight… … Bloom) (#2212) * [P0] Add shadcn/ui foundation with Midnight Bloom tokens Implements P0-01..P0-16 of UN_SHADCN_IMPL_PLAN.md (spec: UN_SHADCN_SPEC.md). Installs the shadcn/ui + Tailwind v4 stack alongside Ant Design; antd still renders every screen, so this phase is intentionally a no-op visually. - Deps: Radix primitives, CVA/clsx/tailwind-merge, lucide-react, next-themes, sonner, react-hook-form + zod, Tailwind v4. antd deliberately retained for the coexistence period (spec §7). - Fonts: self-hosted @fontsource Inter + Geist Mono (no CDN; prod serves via nginx and must not depend on an external host). - Tokens: src/index.css now carries the Midnight Bloom light+dark palette (D8). Tailwind is imported first so its layer ordering is correct, and the colour tokens are mapped with `@theme inline` — with a plain `@theme` Tailwind snapshots the light value and dark mode silently breaks. - Legacy CSS vars renamed to --legacy-* (D6): variables.css defined --primary and --secondary, which collide with the shadcn tokens. - 32 primitives generated into src/components/ui, plus hand-written spinner and kbd (no registry entry) and success/warning badge variants. - Theme: next-themes ThemeProvider mirrors the existing session theme onto the `.dark` class. How the theme is persisted and toggled is unchanged (C4). - Toasts: sonner Toaster mounted and a shared useAppToast helper added for cloud plugins to import (D9). ALERT_SURFACE keeps antd as the single active notification surface until P2-06, so alerts are not double-rendered. Two fixes the plan did not anticipate, both required: - .gitignore: the Python `lib/` rule also matched frontend/src/lib/, which is where components.json points `@/lib/utils`. Without the negation, cn() would never reach the repo and every primitive would fail to resolve in CI. - biome.json: enable css.parser.tailwindDirectives, otherwise Biome cannot parse @theme/@plugin/@custom-variant and fails CI with 4 parse errors. Gates: build (plugins absent, the optionalPluginImports path) passes; 16 tests green; dark mode verified in headless Chromium — the `bg-background` utility itself flips rgb(250,250,250) -> rgb(26,26,26), proving `@theme inline` works; no visual regression (antd element count, button geometry, colours and radii all unchanged — only the body font moves to Inter, which is intended). Lint findings that remain (3 errors, 24 warnings) are pre-existing: pristine main reports 227/261 with the same binary, and none of the findings are in files this change touches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P1-01/P1-02] Migrate @ant-design/icons to lucide-react Implements P1-01 (mapping table) and P1-02 (apply migration) of UN_SHADCN_IMPL_PLAN.md. 91 files, 87 unique icons, zero @ant-design/icons imports remaining in OSS. docs/icon-map.md records every mapping and flags the ones that are not exact, since lucide is not a 1:1 replacement for antd's icon set: - CheckCircleFilled / PlayCircleFilled / InfoCircleFilled -> lucide has no filled variants, so these render as outlines. Where the solid weight carries meaning, the doc shows the fill-current treatment. - MoreOutlined -> EllipsisVertical, NOT Ellipsis. antd's renders vertical (the 10 call-sites are all overflow menus); plain Ellipsis is horizontal. - CaretDownOutlined -> ChevronDown trades a solid triangle for a stroke, which also matches the shadcn/Radix idiom used elsewhere. - SlackOutlined -> MessagesSquare. lucide dropped brand icons, so the Slack glyph is simply gone; this is the one place a brand mark is lost. - ScheduleOutlined -> CalendarClock, ArrowsAltOutlined -> Move, ExportOutlined -> ExternalLink: closest available, no exact match. Three name collisions the rename introduced, all fixed with aliases: - FileUpload.jsx and FileWidget.jsx import antd's `Upload` COMPONENT, which the lucide `Upload` icon shadowed. Left unfixed this would have broken both file upload widgets, not merely the icon. - Workflows.jsx defines its own `User` component; importing lucide's `User` made it render itself. This was an infinite recursion, caught by the build. useRetrievalStrategies.js needed a matching update: RetrievalStrategyModal's ICON_MAP keys were renamed to lucide names, but the hook still emitted antd names, so every lookup would have missed and silently fallen back to the default icon. The backend contract is unchanged — only the frontend key names moved. Verified: build passes; 16 existing tests green plus a temporary smoke test confirming migrated icons render as lucide svgs; lint reports 0 errors and the same 24 pre-existing warnings; no page errors at runtime. The 4 `anticon` elements still in the DOM belong to antd's own notification component, not app code, and go away with P2-06. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P1-03] Move Typography off antd via a compatibility shim Implements P1-03 of UN_SHADCN_IMPL_PLAN.md. 93 call-site files plus a new `@/components/ui/typography` primitive. Zero antd Typography imports remain. Deviation from the plan, and why: the plan said convert Typography to "semantic tags + Tailwind type classes". That is unsafe here. antd's `ellipsis` prop is behaviour, not styling — `ellipsis={{ tooltip: true }}` truncates AND surfaces the full text on hover, and `ellipsis={{ rows: 2 }}` clamps to N lines. 12 call-sites use the object form. Swapping in a bare `truncate` class would silently drop the tooltip, which is a behaviour regression and therefore a C4 violation, not a restyle. So this adds a small shim that presents antd's API (`type`, `strong`, `italic`, `delete`, `code`, `mark`, `ellipsis`, `level`, and the `Typography.Text` namespace) on top of Midnight Bloom tokens, with `ellipsis` implemented against the shadcn Tooltip. The 295 call-sites then become an import rewrite with the JSX untouched: same elements, same order, same props. Per D9/§5.0 it lives in OSS so cloud plugins import the same component. Two details worth noting: - The line-clamp classes are written out in a lookup table rather than interpolated as `line-clamp-${rows}`. Tailwind scans source statically and never sees a class name assembled at runtime, so the interpolated form would have produced no CSS. - The tooltip renders whenever requested rather than only when text actually overflows. antd measures the DOM to decide; matching that would need a resize observer per element. Showing it unconditionally keeps the content reachable, which is the purpose of the prop. 11 unit tests cover the shim, including the ellipsis behaviours that made the regex approach unsafe. Full suite is 27 tests across 5 files, all green. Build passes, lint reports 0 errors and the same 24 pre-existing warnings, and the app renders with no console errors (antd element count drops 33 -> 29 on the landing page as Typography moves off antd). Plan estimate correction: P1-03 was scoped at 158 sites; the real count is 295 (192 `<Typography.Text>` alone). As with icons (43 -> 87), the original enumeration missed multi-line import blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P1-04] Move Button off antd via a compatibility shim Implements P1-04 of UN_SHADCN_IMPL_PLAN.md. 70 call-site files plus a new `@/components/ui/antd-button` wrapper over the shadcn primitive. Zero antd Button imports remain. Same reasoning as the Typography shim (P1-03): antd's Button carries behaviour that shadcn's does not, so the plan's prop-mapping-by-find-and-replace would have changed what the UI does, not just how it looks (C4): - `loading` (234 usages) swaps in a spinner AND disables the button. Dropping the disable would let users double-submit during in-flight requests. - `icon` (106) is a leading slot, not a child. - `danger` (12) is orthogonal to `type`, so it is not a 1:1 variant mapping — danger+text has to stay ghost-with-destructive-text rather than becoming a solid destructive button. - `htmlType` maps to the DOM `type` attribute, because antd claims `type` for its visual variant. The shim defaults DOM type to "button" so a converted button cannot accidentally submit a form. The mapping is type=primary->default, link->link, text->ghost, dashed/default->outline, with danger overriding to destructive (or ghost + destructive text for text/link). size small->sm, large->lg, and icon-only buttons get the icon size. CustomButton (76 usages) is a thin pass-through over antd's Button, so it now routes through the shim automatically — no separate conversion needed. 12 unit tests cover the shim, focused on the behaviours that made the naive approach unsafe: loading disables, loading hides the icon, danger+text styling, htmlType mapping, block/shape. Full suite is 39 tests across 6 files, green. Verified in the browser: antd button count on the landing page drops to 0 while the Login button keeps its exact geometry (50px tall, same colour and position) and total antd elements fall 29 -> 24. Radius moves 6px -> 8px, which is the intended Midnight Bloom --radius-md token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P1] Formalise the shim convention; drop dead antd Button theme Follow-up to P1-03/P1-04, no behaviour change beyond one deletion. - docs/shim-convention.md records the rule the next ~15 components follow: shim when antd implements behaviour shadcn does not, direct swap when the difference is only styling. Names compatibility layers `antd-<component>.jsx` so they read as migration debt with an exit, lists the decision (with usage counts) for every remaining component, and flags `Space` — it wraps each child in its own div, so replacing it with `gap-*` silently breaks any CSS selector matching `> *`. - Renamed typography.jsx -> antd-typography.jsx (94 import lines) so both shims follow that convention rather than one each. - Removed the now-dead `components: { Button: { colorPrimary: "#092C4C" } }` override from ConfigProvider. No antd Buttons remain after P1-04, so it styled nothing. Worth stating plainly, because the P1-04 message did not: that override was painting every antd primary button the old Unstract navy. They now take --primary from Midnight Bloom, so primary buttons across the authenticated app move navy #092C4C -> violet #6f5cef. That is the intended end state under D8, but it is a site-wide colour change and the earlier "geometry preserved" note only covered the unauthenticated landing page, where the Login control is not an antd Button. Build, 39 tests and lint all green after the rename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P1-05] Move Space/Row/Col/Flex off antd via a layout shim Implements P1-05 of UN_SHADCN_IMPL_PLAN.md. 74 call-site files plus `@/components/ui/antd-layout`. Zero antd Space/Row/Col/Flex imports remain. The plan classified these as a direct swap to flex/grid utilities. They are not, for a concrete reason: antd's `Space` wraps every child in its own `.ant-space-item` div, and Row/Col emit `.ant-row`/`.ant-col`. This repo has 20 hand-written CSS rules that select those internals — e.g. `.ant-space .ant-space-item .ant-card` in onBoard.css and `.file-history-modal .action-buttons .ant-space`. Collapsing the wrappers into `gap-*` on the parent deletes the elements those selectors match, so the styling silently stops applying: a regression, not a restyle (C4). 22 Space call-sites also build children from `.map()` or conditionals, where per-child wrappers change what `> *` matches. So the shim keeps antd's DOM shape, including the `ant-*` class names the existing CSS targets, while dropping the antd dependency. Those class names are emitted deliberately and go away in P4 when the dependent CSS is cleaned up. Details preserved: antd's size tokens (small/middle/large -> 8/16/24px) and numeric/array sizes; Space's falsy-child filtering, so conditional children do not leave empty gaps; the 24-column Col basis with span/offset as percentages; and Row's negative-margin + Col-padding gutter model. 11 unit tests cover the shim, centred on the wrapper-div structure that the existing CSS depends on. Full suite is 50 tests across 7 files, green. Build passes and lint is back to the 24-warning baseline with none in the new file (the two I introduced were single-line if-returns, now braced). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P1-06] Move remaining leaf components off antd Implements P1-06 of UN_SHADCN_IMPL_PLAN.md, completing phase P1. 51 call-site files plus `@/components/ui/antd-leaves` covering Tag, Spin, Alert, Image, Divider, Empty, Avatar and Progress. These are the "direct swap" tier of docs/shim-convention.md — none of them carry behaviour the shadcn primitives lack. They are still gathered behind one module so ~60 call-sites convert by import instead of hand-rewriting JSX, which keeps the diff mechanical (C4). Checked before deciding, per the convention: `Spin` has ZERO `spinning={...}` usages, so there is no overlay mode to reproduce and no wrapper is needed — every site is a bare indicator. Most already route through the existing SpinnerLoader widget, which now picks up the shim automatically. Mapping notes: - Tag colour tokens fold onto Badge variants (success/green -> success, error/red -> destructive, and so on). One call-site passes a raw `rgb(45, 183, 245)`, which antd would have applied directly, so unrecognised colours fall through to inline style rather than being dropped. - Alert keeps message/description/showIcon/closable/banner, with its own dismiss state so `closable` still works. - Image does not reimplement antd's `preview` lightbox: no call-site enables it. If one appears later it needs a real implementation, not a prop no-op. Build passes, 50 tests green, lint back to the 24-warning baseline with none in the new file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P2] Move overlays and notifications off antd Implements P2-01..P2-06 of UN_SHADCN_IMPL_PLAN.md. 76 call-site files plus `@/components/ui/antd-overlays` (Modal, Tooltip, Dropdown, Popconfirm, Popover, Collapse) and `@/hooks/useConfirm`. P2-01 useConfirm: promise-returning confirm dialog over AlertDialog, so `if (await confirm({...}))` replaces antd's callback-style Modal.confirm. OSS owned per D9 because the 3 cloud Modal.confirm sites must import it rather than reimplement it. It resolves false on Escape and outside-click, so the promise can never dangle. P2-02..P2-05 overlays. Behaviours preserved that a prop swap would have lost: - Modal renders an OK/Cancel footer BY DEFAULT and only omits it for footer={null}. Call-sites relying on the implicit footer keep their buttons. - The legacy `visible` alias still works alongside `open` (2 sites use it). - destroyOnClose unmounts the body, which Radix does not do on its own. - confirmLoading disables OK, matching the Button shim's loading semantics. - closable={false} hides the close affordance; this shadcn DialogContent renders it unconditionally, so it is suppressed by class rather than prop. - Dropdown accepts antd's `menu={{ items }}` data shape and maps it onto Radix's composed children. - Popconfirm routes onto AlertDialog so inline confirms and useConfirm() share one behaviour rather than diverging. P2-06 notifications: sonner is now the only surface. The ALERT_SURFACE flag, antd's notification.useNotification(), the Close/Close All buttons and contextHolder are all removed. showAppToast now accepts a React node so the rendered markdown + Execution/Request ID lines carry over unchanged, and a `message` export mirrors antd's imperative message.* API for the 3 files that used it. Toaster is positioned top-right to match where antd's stack appeared (sonner defaults to bottom-right) — C4. Verified in the browser: 2 sonner toasts render, 0 antd notifications, and total antd elements on the landing page fall 24 -> 3. Build passes, 68 tests across 9 files green (18 new), lint back at the 24-warning baseline with none in the new files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P3-01..P3-03] Move Form and data-entry controls off antd Implements P3-01 (pattern), P3-02 (bulk Form conversion) and P3-03 (input controls). 61 call-site files plus `@/components/ui/antd-form` and `@/components/ui/antd-inputs`. This is the phase the plan flagged as highest risk, and the reason is the imperative form API: the codebase drives antd Forms through a form instance — setFieldsValue on edit, `await form.validateFields().catch(() => null)` as the submit guard, resetFields on cancel — across 14 useForm() sites and 102 Form.Items. Hand-rewriting those onto raw react-hook-form would be 102 independent chances to change submit or validation behaviour, and one missed guard silently submits invalid data. So antd's Form surface is reimplemented on react-hook-form and call-sites convert by import alone. docs/form-pattern.md records the pattern, with GroupCreateEditModal as the worked reference (it exercises setFieldsValue, the validateFields guard, resetFields and a required rule). The load-bearing detail: validateFields REJECTS when invalid. Two tests pin it — one asserts the rejection reaches `.catch()`, one asserts onFinish does not fire while a required field is empty. antd rule objects (required/min/max/ pattern/custom validator) are translated to RHF options, and a thrown validator error becomes the inline message. P3-03 covers Input (+ TextArea 14 sites, Password, Search), Select, Checkbox, Switch, Radio and InputNumber. The awkward part is onChange shape: antd hands a DOM event to Input but a raw value to Select/Switch, and gives Checkbox an event with target.checked where Radix gives a boolean. Call-sites are written against antd's convention, so the shim rebuilds those shapes instead of rewriting ~90 handlers. Select accepts both `options` data and Select.Option children (6 files use the latter). Build passes, 78 tests across 10 files green (10 new for the Form shim), lint back at the 24-warning baseline. antd importers now 73 files, down from 163 at the start of P1. Note: the new tests use @testing-library/user-event v13's direct API, not v14's `.setup()` — this repo is on v13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P3-04..P4] Remove Ant Design entirely from the OSS frontend Completes P3-04, P3-05 and all of P4. antd, @ant-design/icons, @rjsf/antd and @react-awesome-query-builder/antd are gone from package.json, and `grep -rl "from 'antd'"` over src/ returns nothing. P3-05 (RJSF) turned out far smaller than D3 assumed. RjsfFormLayout already supplies its own `widgets` and `templates` for every field type, so @rjsf/antd was contributing only theme chrome — swapping the import to @rjsf/core is the whole change. There was no widget registry to rebuild. P3-04 (date/time) follows D7 deliberately: the pickers are rebuilt on native date/datetime-local/time inputs, but they still EXCHANGE MOMENT OBJECTS, because call-sites are written as `value={moment(v)}` and `onChange={(d) => onChange(d?.toISOString())}`. Dropping moment would change timezone/DST behaviour, which D7 says needs its own reviewed pass — so this change stays confined to the widget layer and moment remains a dependency. P4 adds the shared DataTable (D5/D9) over TanStack + shadcn table, presenting antd's Table API (columns/dataSource/rowKey/rowSelection/pagination/loading) so all 16 call-sites convert by import and both repos share one table implementation. antd-structure covers the remaining Card, Tabs, List, Layout, Upload, Result, Drawer, Menu, Segmented, Pagination, Steps, Tree and Skeleton. Final removals: - ConfigProvider dropped from App.jsx; next-themes already owns theming. - theme.useToken() replaced by the --card CSS variable. - The three deep imports (antd/es/tabs/TabPane x2, antd/es/input/Search) now resolve to Tabs.TabPane and Input.Search on the shims. - antd-vendor manual chunk, the antd optimizeDeps entries, and the Less preprocessor option (antd was the only Less consumer) removed from vite.config.js. - Query builder swapped to @react-awesome-query-builder/ui, promoted to a direct dependency because the cloud overlay has no manifest of its own (D4). Note on the DOM: three `ant-row`/`ant-col` elements still appear at runtime. Those are emitted deliberately by the P1-05 layout shim because 20 hand-written CSS rules select them; they are our class names, not antd. They go away when that CSS is cleaned up. P4 exit gate: 0 antd imports in src, 0 antd entries in package.json, build passes, 78 tests green, no runtime page errors. Lint shows 3 errors and 26 warnings, all pre-existing — the errors are two SVG assets byte-identical to main, and none of the findings are in files this migration added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [Phase C] Add shared components the cloud plugins need Additions to the OSS shim layer surfaced while converting the enterprise plugins. They live here rather than in the plugins per D9/§5.0 — a component needed by more than one call-site is OSS-owned, so the two repos cannot drift. antd-structure gains four components used only by cloud plugins today: - Descriptions (4 sites) — label/value grid - Statistic (2) — figure with prefix/suffix/precision - FloatButton (2) — fixed-position action button - Transfer (2) — dual list with move-between controls - Badge — antd's count/dot overlay. Note this is NOT shadcn's Badge, which is a pill label; antd calls that one Tag. Naming them apart avoids a confusing collision later. useAppToast gains a `notification` export mirroring antd's imperative notification API, including the useNotification() hook form that returns [api, contextHolder]. antd's config shape is `{ message, description }` while sonner takes a title plus `{ description }`, so the remap happens here instead of at each call-site. Verified both ways: the OSS build passes with src/plugins absent (the optionalPluginImports path), and the P0-G2 overlay build passes with all 53 plugins present and antd uninstalled. 78 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [P4-09 + gaps] Complete the CSS cleanup, shim tests, icon map and scroll-lock check Closes the four items I previously reported as complete but had not actually finished. Each is now verified against the plan's own criteria rather than by assertion. P4-09 — final cleanup. Its verify command is `grep -rn "legacy-" src/` -> 0. It was 72. The 42 remaining var() references across 8 legacy variables are now mapped onto Midnight Bloom semantic tokens and variables.css is deleted: --legacy-page-bg-1/2/3 -> var(--card) / var(--background) / var(--muted) --legacy-white -> var(--card) (it flipped to #000 in dark, so it was a surface, not white) --legacy-black -> var(--foreground) (flipped to #fff in dark) --legacy-border-color-* -> var(--border) --legacy-font-family -> var(--font-sans) --legacy-font-size/weight-* -> literals; Tailwind stock matches them exactly Visible effect: the page background moves #e9e9e9 -> #fafafa, and body background now follows the theme, which the legacy vars only did for a few surfaces. Dark mode re-verified end to end after the file was removed. docs/icon-map.md was stale — it documented 43 icons from the first enumeration pass, but the real set is 116 (87 OSS, 87 cloud, overlapping). Regenerated from the verified map with true pre-migration usage counts pulled from git, and 27 inexact pairs called out with the reason each differs: lucide has NO filled variants (8 icons render lighter), it dropped brand icons (Slack is simply gone), and several are approximations (FilePdf -> FileText loses the format hint). This is the artifact a reviewer needs to sanity-check those calls. Four shims had no tests, which contradicts the rule in shim-convention.md that every shim must cover the behaviours justifying it. Added 67 tests: - antd-inputs (14) — the onChange CONVENTIONS, which differ per component and which Radix inverts: Input gets an event, InputNumber a number, Checkbox an event with target.checked, Switch a boolean. - antd-datetime (14) — the D7 contract, i.e. onChange hands back a MOMENT so `date?.toISOString()` at the call-sites keeps working. - antd-leaves (17) — including the raw rgb() Tag colour that must not be dropped just because it is not a known token. - antd-structure (22) — DataTable's antd column/render contract, and Badge's count/overflow/showZero rules. P2-02's deferred `body { overflow: hidden }` check is done. Radix's dialog scroll-lock also sets body overflow and restores the prior value on close; the risk was it restoring the wrong one and leaving the fixed app shell scrollable. Three tests pin it: overflow stays hidden before/during/after, survives repeated cycles, and is NOT left hidden on pages that never pinned it. The index.css comment now records the outcome instead of reading as a TODO. One real bug surfaced while writing these tests: the Tabs shim passed both `value` and `defaultValue` to Radix, and a present-but-undefined `value` makes Radix treat the component as controlled — which would have frozen every uncontrolled tab set. Now it passes exactly one. Test suite: 148 tests across 15 files, up from 78. Build passes, lint at the 24-warning baseline with zero findings in migration files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Commit the lockfile changes from the antd removal The dev-deploy frontend image build failed: error: lockfile had changes, but lockfile is frozen process "/bin/sh -c bun install --frozen-lockfile --ignore-scripts" did not complete successfully: exit code: 1 `bun remove antd @ant-design/icons @rjsf/antd @react-awesome-query-builder/antd` and the `@tanstack/react-table` / `@react-awesome-query-builder/ui` additions updated bun.lock in the working tree, but that file was never staged — every earlier commit staged explicit paths and bun.lock was not among them. So the committed lockfile still listed antd as a root dependency and was missing @tanstack/react-table, which is exactly the desync --frozen-lockfile exists to catch. Nothing about the migration changes; this is the manifest edits reaching git. Why local checks did not catch it: `bun install --frozen-lockfile` in the worktree passes, because it validates the WORKING lockfile, which was already correct. Only a clean checkout — i.e. Docker — sees the committed one. Verified the fix by copying package.json + bun.lock into an empty directory and running the container's exact command there: exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Layout and Sider shims dropped antd's sizing behaviour Two bugs found by comparing the dev deployment against production. Both are in the P4 structure shim, and both produce a page that is "correct" in the DOM but broken on screen — so no test, build or lint caught them. 1. Layout had no flex-grow. antd's Layout is `flex: auto`; mine computed `flex: 0 1 auto` and resolved to height 0. Every descendant using `flex: 1` then collapsed: on the dashboard, `.metrics-dashboard-container` was 0px tall while its child was 212px, so the whole page rendered at y=858, below a clipped viewport. The content was in the DOM the entire time, which is why it looked like a data problem rather than a CSS one. Layout.Content had the same issue (`flex-1` vs antd's `flex: auto`). 2. Layout.Sider ignored `collapsed` / `collapsedWidth`. It always applied `width`, so with a stored `collapsed: true` preference the rail sat at the full 240px while SideNavBar hid every label behind `!collapsed` — an icons-only sidebar in an expanded gutter. `collapsible` and `collapsedWidth` were also leaking onto the DOM as invalid attributes. Layout now also switches to a row when it contains a Sider, matching antd's hasSider auto-detection. That is done via an explicit `__isSider` marker rather than `c.type === Layout.Sider`: the identity check is fragile because Sider is assigned after Layout and does not survive HMR or wrapping. 7 regression tests cover both: flex-auto on Layout and Content, row/column switching, collapsed vs expanded width, and no antd-only props reaching the DOM. Full suite 155 tests, build and lint green. Worth noting for the remaining review: this is the class of defect the shim unit tests structurally cannot catch. They assert rendered output in jsdom, which has no layout engine — height 0 and height 212 look identical there. Only a real browser shows it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Detect Sider at runtime so Layout lays out as a row Follow-up to the previous Layout fix, which was only half right. `flex-auto` landed and the Sider collapse fix worked (the rail correctly renders at 65px now), but the dashboard was still empty: `.metrics-dashboard-container` remained 0px against production's 607px. The reason is the OTHER half of antd's Layout behaviour. A Layout containing a Sider lays out as a ROW; mine stayed a column, so the content area got no height. My first attempt inferred this from `React.Children`, which cannot work here: PageLayout renders `<SideNavBar>`, and the Sider lives *inside* that component. Compile-time child inspection can never see it. antd solves this with runtime context, so this does too — a Sider registers itself with the nearest ancestor Layout on mount, however deeply nested. The `__isSider` marker from the previous commit is gone; it was unreachable. Confirmed against production, whose outer Layout is `ant-layout ant-layout-has-sider` with `flex-direction: row` at 713px, versus mine at `flex-col` and 0px. The new test renders a Sider inside another component, matching how the real app does it — the earlier test passed a Sider as a direct child, which is exactly the case that already worked and why the bug survived. 156 tests, build and lint green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Modal `centered` broke the dialog's centring transform Third layout defect found by comparing the dev deployment against production. On the workflows page the "Create Prompt Studio" dialog rendered at y=-109 with `transform: none` — pinned to the top of the viewport with its header clipped off-screen. Cause: shadcn's DialogContent is ALREADY centred, via `top-[50%] translate-y-[-50%]`. My Modal shim treated antd's `centered` prop as something it had to implement and appended `top-1/2 -translate-y-1/2` — the same geometry spelled differently. tailwind-merge sees two competing translate/top utilities, keeps one, and the dialog ends up with no transform at all. antd's `centered` is therefore a no-op here: the base component already does it. The prop is still destructured so it cannot land on the DOM as an invalid attribute, with a comment explaining why it is deliberately unused — otherwise this looks like an oversight and gets "fixed" back. Two regression tests: the base translate utilities must survive alongside `centered`, and the conflicting spelling must be absent. Audited the other shims for the same pattern (a wrapper adding positioning utilities on top of a shadcn primitive's own). The remaining `absolute`/`fixed` classes in antd-leaves and antd-structure are on elements those shims create themselves, so there is nothing to conflict with. 158 tests, build and lint green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] List ignored antd's grid prop, stacking adapter cards Fourth defect found against the live deployment. The Add LLM / Add Connector pickers render `<List grid={{ gutter: 16, column: 4 }}>`. antd switches to an n-column grid for that; my shim always rendered a divided vertical list, so every adapter appeared one-per-row in a 600px scroller instead of 4-up. Measured in the browser: `.list-of-srcs` children all sat at the same x with display:block. The shim now honours `grid.column` (grid + grid-cols-n) and `grid.gutter` (gap), and keeps the stacked divide-y list when no grid prop is passed. Column classes are written out in a lookup rather than interpolated, since Tailwind scans statically — same reasoning as the line-clamp table in antd-typography. Two tests: grid mode applies grid-cols-4 and the gutter and drops divide-y; non-grid mode still stacks. 160 tests, build and lint green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Tall modals overflowed the viewport with no scrollable body Fifth defect found against the live deployment. The Add LLM adapter settings form (RJSF) rendered 1109px tall in an 800px viewport. The dialog was pushed to y=-194 and the Submit button sat off-screen, so the form could be filled in but never saved. antd wraps modal content in `.ant-modal-body`, and this app's CSS caps that element — `.add-source-modal .ant-modal-body { height: 695px; overflow: hidden auto }`, `.retrieval-strategy-modal .ant-modal-body { max-height: 70vh }` and several more. My Modal shim rendered children directly into DialogContent, so none of those rules matched anything and nothing constrained the height. Content is now wrapped in a `.ant-modal-body` element. The class name is what makes the existing per-modal CSS work again; the `max-h-[70vh] overflow-y-auto` on it is the fallback for modals that never had a bespoke rule. Found while verifying P3-05: the RJSF form itself is correct on @rjsf/core — 9 inputs, 3 required markers, descriptions, prefilled defaults, password reveal, and Test Connection / Submit / Close all render. It was only unreachable. 161 tests, build and lint green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Emit the antd class names the app's own CSS still targets This is the systemic cause behind most of the layout defects found against the live deployment, rather than another one-off. The app has ~200 hand-written CSS rules that target antd's internal class names — `.ant-card-body`, `.ant-modal-content`, `.ant-tabs-nav`, `.ant-table-body`, `.ant-btn`, `.ant-typography` and ~65 more. antd emitted those elements; my shims did not, so every one of those rules silently matched nothing. 109 of them set layout properties (height, overflow, display, flex, padding), which is exactly why screens looked structurally right in the DOM and wrong on screen. Measured before and after: **109 dead layout rules across 53 classes → 8 across 8**. The 8 that remain are leaf styling on features this app does not currently render (card meta, textarea counters, tab overflow controls). The shims now emit the class names alongside their Tailwind classes. This is deliberate coupling to the legacy CSS, not an accident, and it is temporary: when that CSS is eventually rewritten against the design tokens, the hooks come out. The P1-05 layout shim already did this for `.ant-space-item`/`.ant-row`; this extends the same approach to the rest. Also fixed while here: Divider, Radio.Group/Radio, Segmented items, Popover inner, Result subtitle, Dropdown menu items and Collapse header/content were missing their hooks. Found by static audit rather than by opening screens — the previous five bugs were each discovered one page at a time, which does not scale and would have missed the ones on screens nobody happened to visit. 161 tests, build and lint green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Collapse.Panel and Card.Meta were undefined, crashing Prompt Studio Most severe defect so far: opening any Prompt Studio project showed "Couldn't load this page" and rendered nothing. Cause: PromptCardItems.jsx and NotesCard.jsx render `<Collapse.Panel>`, and SetOrg.jsx renders `<Card.Meta>`. Neither sub-component existed on the shims, so React received `undefined` as an element type and threw error #130. That does not degrade one component — it takes down the entire route. Collapse now supports both antd forms: the `items` data prop and the legacy `<Collapse><Collapse.Panel header=…>` children, including `showArrow={false}` which PromptCardItems relies on. Card.Meta renders avatar/title/description. Added a completeness guard (shim-completeness.test.jsx) instead of only fixing the two. It scans the app source for every `<Foo.Bar>` usage and asserts the shims actually expose it. The per-component tests could not have caught this: nothing in them rendered Collapse.Panel, so its absence was invisible until a real page tried. The guard covers 14 sub-components today and fails loudly for any future gap. It earned its place immediately — it caught that my first Collapse.Panel assignment had not landed (biome had reordered the export block my patch anchored to, so the edit silently no-opped). 176 tests across 16 files, build and lint at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Modal.useModal was undefined, crashing 12 components on click Found by static audit rather than by clicking: scanning for `Foo.bar(...)` calls on shim components turned up two undefined statics. ConfirmModal calls `Modal.useModal()` and then `modal.confirm({...})`. Neither existed, so every consumer threw a TypeError the moment its button was clicked. That is 12 components — delete actions across prompt studio, workflows, manage docs, LLM profiles, custom synonyms and the top nav. useModal now returns `[api, contextHolder]` and implements confirm/info/ success/error/warning/destroyAll on AlertDialog, so it shares behaviour with useConfirm() instead of becoming a second confirm pattern. Escape and outside-click resolve as Cancel. Modal.confirm is implemented too — the fully-imperative form callable outside React, which mounts its own root. No OSS call-site uses it today, but the cloud plugins have three. Extended the completeness guard to cover static calls, not just `<Foo.Bar>` JSX. It now strips comments before scanning: a doc comment mentioning `Modal.confirm` is not a call-site, and flagging it would teach people to ignore the test. 180 tests across 16 files, build and lint at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] App CSS `top: 20px` fought the Dialog's centring Eleventh defect. The workflows "Create Prompt Studio" dialog rendered at y=-127 with its header clipped off-screen, even after the earlier centring fix. That earlier fix was correct — the classes were right this time. The override came from the app's own stylesheet: .prompt-studio-modal { padding: 10px; top: 20px; } antd's modal wrapper is statically positioned, so `top: 20px` read as "20px from the top of the viewport" and worked. The shadcn Dialog is `position: fixed` and centres itself with `top: 50%` + `translateY(-50%)`, so the same rule overrode the centring while the transform still applied — pulling the dialog 127px above the viewport. Removed the rule and left a comment explaining why, since it looks arbitrary otherwise. Centring is the component's job now. Added css-collisions.test.js rather than only fixing the one rule: it scans every stylesheet for a modal/dialog ROOT selector setting top/bottom/transform and fails with the offending file and rule. It deliberately ignores inner elements (`__body`, descendant selectors, `.ant-*`), which cannot fight the root's positioning. The remaining `.retrieval-strategy-modal__*` rules are inner elements and are correctly not flagged. This is the third distinct failure mode that jsdom cannot see (height 0, dead CSS hooks, and now positional overrides), so it is worth having a static guard rather than relying on someone opening the right screen. 182 tests across 17 files, build and lint at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Buttons dropped refs, leaving every Dropdown trigger inert Twelfth defect, and the most silent one yet: Prompt Studio's Export button did nothing. No menu, no error, no network request — I instrumented fetch and XHR to confirm zero calls were made. Export is the child of a `<Dropdown>`, and Radix renders its trigger with `asChild`, attaching handlers through a ref. Neither CustomButton nor the base shadcn Button forwarded refs, so the ref went nowhere and the trigger was never wired up. A dropped ref throws nothing and logs nothing, which is why this survived 182 passing tests and a full route sweep — the page rendered fine, the button just wasn't connected to anything. Both now forward refs. That covers the 24 Dropdown call-sites, plus Popover and Tooltip triggers that use the same asChild mechanism. Audited the other primitives: Badge, Kbd, Label, Skeleton and Spinner are also plain functions, but none is used with asChild anywhere, so they are not causing breakage. Left alone rather than changed speculatively. Four regression tests: the base Button and CustomButton each forward to a real DOM node, a Dropdown wrapping CustomButton gets aria-haspopup/data-state (proving Radix wired the trigger), and the menu actually opens on click. 186 tests across 18 files, build and lint at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Add Dropdown.Button — the split-button shim was missing Found by the shim-completeness guard once the enterprise plugins were overlaid: ReviewHeader.jsx:910 renders <Dropdown.Button>Download File</...>, and Dropdown.Button was undefined. That is React error #130, which takes down the whole manual-review route rather than just the button — the same failure mode as the Collapse.Panel bug. Dropdown.Button is NOT Dropdown. In <Dropdown> the child IS the trigger, so naively aliasing the two would make "Download File" open a menu instead of downloading. antd's split button keeps the halves separate: children is a real action button wired to onClick, and only the chevron opens the menu. The three new tests pin exactly that separation, since it is the one thing an alias would silently get wrong. The chevron half carries aria-label="More actions" so both halves stay distinguishable by accessible name. * [fix] RangePicker silently ignored five props its call-sites pass The shim accepted `presets`, `disabledDate`, `allowClear`, `onOk` and `format` and did nothing with them. Nothing crashed, so this survived the migration invisibly — but three of the five are behaviour, not decoration: - `presets` MetricsDashboard's "Last 7/30/90 Days" buttons never rendered. Those are the primary way the range gets set, so the control looked finished while its main affordance was missing. - `disabledDate` MetricsDashboard uses it to block future dates. Ignored, users could query tomorrow. Now probed outward from today and mapped onto the inputs' min/max, which is the bound a native input can actually enforce. - `allowClear` antd defaults to true; MetricsDashboard passes false because its handler drops anything that is not a complete pair. Emitting null there strands it on a stale range. `onOk` now fires when a range becomes complete (there is no popup confirm button to hang it off). `format` and `size` are destructured to keep them off the DOM. Also stops forcing moment on the way out. ExecutionLogs holds moment, MetricsDashboard holds dayjs; the shim rebuilt every emitted date as moment, handing MetricsDashboard a type it never opted into. It happens not to break because that code only calls .toISOString(), which both implement — but it quietly reverses D7's promise that this layer does not change what flows through it. Emitted dates are now cloned from the caller's own instance. Each of the five behaviours has a test, and each was mutation-checked: the prop was re-broken one at a time and the matching test failed every time, so these assert the fix rather than restating it. * [fix] Rebuilding a caller's date via `new sample.constructor(iso)` is broken Live check on the dashboard caught this: the preset buttons rendered, but `disabledDate` produced no `max` bound, so future dates were still pickable — the very thing the previous commit claimed to fix. Cause: `new sample.constructor(isoish)` looks like a reasonable way to rebuild a date in the caller's library. It is wrong for both libraries in use. dayjs's internal constructor takes a config OBJECT, so handed a string it ignores it and returns TODAY. moment's returns an object that throws on .format(). So the disabledDate probe compared today against today on every iteration, never crossed the boundary, and yielded no bound. Now clones the caller's instance and re-points it field by field, which both libraries support (dayjs setters return a new instance, moment's mutate and return this; assigning the result covers both). The result is asserted to land on the exact requested instant before it is returned. The reason this got through: the test used a hand-written dayjs-shaped stub whose constructor DID accept a date string, so it validated the stub rather than the shim. Replaced with the real dayjs and moment, plus a case pinning the actual predicate MetricsDashboard passes. Re-broken deliberately to confirm the new test fails against the old approach. * [fix] Every dayjs-valued date field was rendering today's date Caught by driving the deployed dashboard: its range read 28 Jul → 28 Jul when the default is "last 30 days", and clicking a preset appeared to do nothing because the fields already showed today either way. `moment(dayjsInstance)` is the culprit. It does not throw and does not report invalid — it silently returns a moment for TODAY. `toInputValue` called it for anything that was not already a moment, so every dayjs value rendered as today with nothing to indicate it. MetricsDashboard holds dayjs, so its whole range was wrong on screen while its state was correct. Values exposing valueOf() (dayjs, moment, Date) are now normalised through the epoch instant before parsing. Strings are unaffected: String.valueOf() returns the string, so ISO parsing is unchanged. This one predates the previous two commits — the presets and disabledDate work was correct, but sat on top of a display path that had been broken for every dayjs caller since the shim was written. Verified across dayjs, moment, ISO string, Date, unparseable and null; the two new tests fail when the old moment(value) call is put back. * [P3-04] Give RangePicker a real calendar popup Two native date inputs were parity with nothing — antd's RangePicker has always been a two-month calendar with a preset sidebar, so the inputs were a downgrade users would notice. New component for us, not new capability. Adds a shadcn Calendar over react-day-picker v10 (which ships no stylesheet, so every colour is a Midnight Bloom token and it tracks light/dark), and rebuilds RangePicker as a single `ant-picker-range` trigger opening a popover: preset sidebar on the left, two months on the right. The external contract is unchanged and still mutation-tested: moment/dayjs tuples in and out, presets, allowClear, onOk, disabledDate. Two things the calendar does BETTER than the inputs it replaces: - `disabledDate` is per-date in antd, and a calendar greys out individual days. The native inputs could only approximate it by probing outward for a min/max bound. - the whole range is one control, so there is no half-updated state between two separate fields. Two library behaviours worth recording, both found by probing rather than assuming: - react-day-picker reports {from, to} with BOTH set to the clicked day on EVERY click; it does not distinguish opening a range from closing one. Taken at face value, onOk fires on click one and click two restarts instead of completing. An explicit anchor restores antd's semantics and orders the ends so a backwards selection still yields start <= end. - two months plus outside-day overflow means one date can appear twice in the DOM, so the test helper takes the non-outside cell. All six behaviours were re-verified by mutation. The first pass missed one: swapping likeSample for moment inside the disabledDate path went undetected, because the library-preservation tests only covered onChange. Added a test asserting the type the predicate itself receives. bun.lock is updated (not package-lock.json, which is gitignored here) — `bun install --frozen-lockfile` is what the Docker build runs, and an npm-only install would have failed it the way a missing bun.lock did before. * [test] Pin the RangePicker against MetricsDashboard's real prop shape The unit tests each drive one prop in isolation, which is how the earlier dayjs display bug slipped through: every individual assertion passed while the combination users actually see was broken. This renders the exact props MetricsDashboard passes — dayjs values, its disabledDate predicate, allowClear={false}, size, and all three presets — then drives the whole interaction: open the trigger, confirm two months and the preset sidebar, click a preset, and assert the emitted pair is dayjs and spans exactly 7 days. Cheap to run and it fails on any of the regressions this branch has already hit once. * [fix] Calendar popover stacked its months and ran off the screen Live check on the deployed dashboard: the popover opened 250px wide and ~700px tall, bottom edge at 1070px in a ~780px window — the two months were stacked in a column instead of sitting side by side, and the bottom of the calendar was unreachable. Cause: `sm:flex-row` on the months and `sm:flex-col` on the preset sidebar. Tailwind's `sm:` measures the VIEWPORT, but this content lives inside a popover whose own width is what decides the layout. On a wide screen the breakpoint matched and still produced a stacked column, because the popover never gets the viewport's width. Both are now unconditional rows. Worth noting how close this came to shipping: the screenshot was clipped at the viewport edge, so the popover looked plausible until its geometry was measured. jsdom has no layout engine and could never have caught it. The added guard asserts the class contract rather than the geometry — it fails if a `sm:` variant reappears in the popover — and was confirmed by reintroducing the bug. * [fix] Declare dayjs — it was reaching the app only through antd MetricsDashboard.jsx and RecentActivity.jsx both `import dayjs from "dayjs"`, but dayjs was never in package.json. `npm ls dayjs` showed the only path: frontend -> react-js-cron@5.2.0 -> antd@5.29.3 -> dayjs@1.11.19 That is a live hazard rather than a tidiness issue: antd itself is already undeclared (the migration removed it) and survives only because react-js-cron still depends on it. The moment react-js-cron is replaced — which the antd removal work requires anyway — antd goes, dayjs goes with it, and two production components stop resolving an import they have always relied on. Pinned to the exact version already resolving (1.11.19, not a caret range) so declaring it changes nothing at runtime today; it only removes the dependency on a transitive path we intend to delete. * [P4-08] Drop antd entirely — replace the cron picker, guard against its return antd was still in the tree. Every plan task about removing it passed — P4-05 (no imports), P4-08 (not in the manifest), the P4 exit gate — because they all check source imports and package.json. None of them look at the RESOLVED tree, and antd was reachable transitively: frontend -> react-js-cron@5.2.0 -> antd@5.29.3 58MB of framework serving one 38-line component with a single call-site. Replaces react-js-cron with a hand-rolled picker on the existing primitives rather than another library: a new dependency is a new transitive surface, which is the thing being removed. It offers hourly/daily/weekly/monthly plus a custom expression, and validates through cronstrue — the same parser the call-site already uses for its summary, so anything the dialog accepts the surrounding UI can render. Invalid expressions cannot leave the dialog. The contract is unchanged: `setCronValue` still receives a 5-field string. Checked before removing, since the OSS package.json is the only manifest (plan §3.3) and dropping a dep drops it for the cloud plugins too: - react-js-cron is antd's ONLY dependent (npm ls antd) - no cloud plugin imports react-js-cron - no app CSS targets its class surface Result: `npm ls antd` empty, no node_modules/antd, no @ant-design, and no antd runtime markers in any built chunk. Bundle 6.0MB -> 5.7MB, build 45s -> 27s. Adds a fourth static guard beside the existing three. A one-time grep proves today is clean and does nothing about the next dependency bump — and this defect survived the entire migration precisely because nothing failed. It checks all three ways antd can come back (declared, transitive, imported); each was verified by reintroducing it and watching the matching case fail. Also covers the nested-dialog case: CronGenerator renders as a sibling after EtlTaskDeploy's own Modal, so production stacks two Radix dialogs. The existing scroll-lock test covers one. These assert the inner opens over the outer, stays interactive, and leaves body overflow hidden on unmount (P0-12). * [fix] Four UI defects found comparing dev against the reference deployment Walked the app side by side with globe.unstract.com. Four real differences, batched into one deploy. 1. Every uncoloured border drew BLACK (~200 elements per settings page). Tailwind's border utilities set width only; the colour falls back to currentColor. shadcn's setup ships a `border-color: var(--border)` default and ours was missing it, so `border`, `border-b` and `divide-y` all drew solid black where antd had a near-invisible hairline — most obviously as a black rule between every list row. Fixed once in the theme rather than at call-sites, so the next uncoloured border is right by default. 2. A grey block behind the icon button on every prompt card (7 on one Prompt Studio screen). antd applies a Badge's `style` to the COUNT; the shim let it fall through `{...props}` onto the wrapper span, so PromptChangeIndicator's `style={{ backgroundColor: color }}` painted the wrapper. Also implements `offset`, which was accepted and ignored. 3. Invite Users was a blank "Couldn't load this page". antd's NamePath allows arrays — InviteEditUser uses `name={["email"]}` — but react-hook-form calls `.split(".")` on the name, so it threw `TypeError: s.split is not a function` and the error boundary swallowed the whole route. Arrays are antd's dotted path, so joining reproduces it. The cloud StripeProductForm uses nested `name={["tier1", "up_to"]}`, so this was broken there too. 4. Form labels pointed at nothing. `<Label htmlFor={name}>` was rendered but no id was ever set on the control, so clicking a label did not focus its field and screen readers announced it unlabelled. Found while writing the test for #3. Each fix has a regression test, and each was mutation-checked by reintroducing the defect and confirming the matching test fails. * [fix] Replace hardcoded antd blues with design tokens Comparing dev against the reference surfaced link buttons still painted antd's #1677ff — the Create Prompt Studio dialog renders its actions in antd blue rather than Midnight Bloom purple. antd is gone, so nothing supplies those colours any more; they were simply hardcoded, and 42 `!important` declarations in PromptStudioModal.css meant they beat the token styling wherever they appeared. 26 declarations across 8 files, all now `var(--primary)`. Also converts the black-alpha text in that file — `rgba(0,0,0,.45)` and `rgba(0,0,0,.88)` — to `--muted-foreground` / `--foreground`. Those are invisible-on-invisible in dark mode, which the old antd theme never had to handle. Colour-only; no structural change, and the full suite is unaffected. * [fix] Loader centring and an unscrollable sidebar Two reported issues, both traced to the same kind of cause: markup or sizing the shim quietly changed. LOADER — the logo was neither horizontally centred nor aligned with its text. `.center` (index.css) sizes itself with `width/height: inherit`, which does not resolve to the viewport; it collapsed to the height of its own content (measured: 24px), so the box landed wherever it fell rather than mid-screen. LazyOutlet.css already carries a patch for `.center` for exactly this reason. Scoped the fix to `.generic-loader` so the other consumer — FileSystem's inline empty state — keeps sizing to its container, and made `.spinner-box` a centred flex column so the logo, pulse dots and text share one axis. Measured after: logo centre-x 768 in a 1536 viewport, and equal to the text's centre-x. SIDEBAR — bottom menu items were cut off and unreachable. Two causes: 1. The Sider shim rendered children bare. antd wraps them in `.ant-layout-sider-children`, and SideNavBar.css depends on that wrapper: it is the flex column which clamps `.sidebar-content-wrapper` so its `overflow-y: auto` has something to scroll against. Without it the wrapper grew to its full 929px content height inside a 668px rail, so `auto` never engaged. 2. `.side-bar.ant-layout-sider-collapsed .sidebar-content-wrapper` set `overflow-y: hidden` under a comment saying "hide scrollbar". The scrollbar was already hidden by `scrollbar-width: none`; that rule was disabling SCROLLING, so the collapsed rail — which is how the sidebar actually renders — could not reach its lower entries on any window shorter than ~1000px. Verified live: both states now scroll, scrollbar still hidden. The reference deployment does not overflow only because its window is taller (766px vs 721px); it has the same content height, so this was latent there too. The Sider wrapper has a regression test, mutation-checked by removing the wrapper and confirming it fails. * fix(frontend): unify primary colour on Midnight Bloom tokens The migration left two competing definitions of "primary". The Button shim maps antd `type="primary"` to the shadcn `default` variant (`--primary`, violet), but CustomButton.css then repainted it navy `#092c4c` — so the 24 CustomButton call-sites rendered a different colour from every plain `<Button type="primary">`. Rather than retokenise that override, it is deleted along with the stylesheet: the shim already produces the right colour, and a second definition would only drift again. Sidebar: `.side-bar` hardcoded `#0d3a63` while `.topNav` (PageLayout.css) uses `var(--primary)`, so the two bars meeting at the top-left corner were different colours. Now both are `var(--primary)`. Deliberately NOT `var(--sidebar)` — that token is white in light mode, and every rule in SideNavBar.css assumes a dark surface. The navy-derived values that no longer work on violet are rebased with it: - `.space-styles` hover/active `#005b82` → a white overlay, which tints whatever `--primary` is in either mode. - `.sidebar-item-text` / `.sidebar-antd-icon` `#b4c2cf` measured 6.41:1 on navy but only 2.6:1 on violet — under even the 3:1 large-text floor. White is 4.72:1 and clears AA; the intermediate tints (#e8e5fb, #dcd8f9) top out at 3.82:1 and do not. Also implements antd's `showCount`, which the Input/TextArea shim dropped into `...props`: three modals asked for a counter and rendered none while `maxLength` still silently truncated typing. Remaining navy in CardGridView/ListView/SetOrg is text, not a surface, so it maps to `--foreground` (or `--primary` for the icon hover accent). Tests 225 → 229; lint holds at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): retire antd's default palette for Midnight Bloom tokens Follow-up to the primary-colour unification. That change only swept the navy family, so antd's stock palette survived — including #1890ff on the sidebar that had just become violet. Sidebar icons were the visible regression. They are inline SVG assets with `fill="#90A4B7"` baked in, a slate grey chosen for navy where it measured 4.54:1. On `--primary` violet it falls to 1.84:1 and the icons all but disappear. The assets are data-URI <img> sources, so `color` cannot reach them; instead the `brightness(0) invert(1)` filter that the hover rule already used is applied at rest and dimmed to opacity 0.85 to match `.sidebar-item-text`, with the active state going to full white so the inactive/active distinction the hover rule drew is preserved. The rest is a role-based conversion, not a find-and-replace: - #1890ff / #1677ff as an accent, link or active state -> `--primary` - the same in `outline:` (focus rings) -> `--ring` - #40a9ff (antd's hover blue) -> `--violet-300` - #52c41a -> `--success`, #faad14 -> `--warning` - `.sidebar-toggle-icon.pinned` -> white; it only needs to read as "on" against the unpinned 60% white, not introduce a third brand colour Chart and per-type icon palettes (MetricsChart, RecentActivity, the Agency progress stroke) are deliberately multi-colour scales and keep their literals — tokenising them would collapse distinct series into one colour. Tests hold at 229; lint at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): restore input border colour, pointer cursors, dragger and modal width Five defects, several sharing one root cause. 1. Input borders looked uncoloured on the left and right. The default-border-colour rule added earlier sat OUTSIDE any cascade layer. Unlayered CSS beats layered CSS regardless of specificity, so it outranked Tailwind's `utilities` layer and repainted every explicitly coloured border. `border-input` was the casualty: form controls resolved to `--border` (#e5e5e5) instead of `--input` (#d3d3d3), leaving inputs a full shade lighter than the antd reference — 1.21 vs 1.41 contrast against the page. The thin left/right edges wash out first at fractional device-pixel widths, which is why only they looked absent. Moving the rule into `@layer base` keeps it as the fallback for uncoloured borders while letting `border-*` utilities win, which is what shadcn intends. The 0.8px border width is NOT a bug: at dpr 1.25 Chrome snaps 1px to one device pixel and reports it back as 0.8px. The antd reference computes 0.8px too. 2. Buttons showed no hand cursor. Tailwind v4 dropped v3's preflight `cursor: pointer` on `<button>`, and antd had set it on every control. Added to the Button variants and to the other primitives that read as clickable: Select trigger and items, dropdown items, checkbox, radio, tab triggers. `disabled:pointer-events-none` still wins for disabled. 3. The emoji picker could not be dismissed with Esc or an outside click. antd call-sites pass `open` and drive it from the trigger themselves; Radix reads a bare `open` as fully controlled, so it had nowhere to report a close. The Popover shim now always supplies `onOpenChange`, and consumes antd-only props (`trigger`, `arrow`, `overlayClassName`) that were landing on the DOM. 4. The same picker was cut off: shadcn's PopoverContent is a fixed `w-72` (288px) with `p-4`, and the picker is wider and brings its own chrome. Now `w-auto` with a viewport-aware max and collision padding. 5. Import Project: the modal rendered at 570px instead of the requested 600 and drifted off-centre, because `width` was applied as `maxWidth` only and shadcn's `w-full max-w-lg` stayed in charge. And `Upload.Dragger` was aliased to `Upload` — an inline span, so the drop zone had no border, no fill, and no drag handlers at all. Dropping a file navigated the browser away to render it. Both fixed; Upload now handles drops. Tests 229 -> 232, mutation-verified; lint at the 24-warning baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(frontend): guard the cascade-layer and cursor regressions Both defects shipped, and neither is visible to jsdom — there is no cascade-layer resolution and no cursor — so they are asserted the way `no-antd.test.js` asserts its invariant: against the source text and the emitted class list. The border one had been reported twice. Unlayered CSS outranks every layered rule regardless of specificity, so the universal border-color selector silently beat Tailwind's `utilities` layer and repainted `border-input`; both mutations (unwrapping @layer base, dropping cursor-pointer) fail these tests. Co-Authored-By: Claude Opus 5 (1M context) <nore…
UN-4017 [FIX] Reject non-mapping outputs at the prompt-output interna… …l API (#2251) * UN-4017 [FIX] Reject non-mapping outputs at the prompt-output internal API `outputs` is indexed by prompt key downstream — OutputManagerHelper. handle_prompt_output_update does outputs.get(prompt.prompt_key) — so a list raised AttributeError inside the helper and surfaced to the caller as a bare 500 with no usable reason: internal_views.py:84 prompt_output -> output_manager_helper.py:179 handle_prompt_output_update output = outputs.get(prompt.prompt_key) AttributeError: 'list' object has no attribute 'get' Reachable from ordinary traffic, not just a malformed client: single-pass extraction passes the LLM's parsed JSON straight through as the outputs map, and that parse yields a list whenever the model wraps its answer in prose, a reasoning block or a stray fence marker. That is why it was intermittent and appeared model-dependent. The view already validated prompt_ids and document_id and simply did not check this one. Validating it here means no future executor can 500 the backend the same way, and the caller gets the reason instead of a 500. Absent `outputs` still defaults to {} and is accepted, unchanged. * UN-4017 [FIX] Say "JSON array", not "non-object JSON", in the outputs error A JSON array is valid JSON. Calling the response "non-object JSON" reads as "malformed JSON" and sends the reader looking for a parse failure that did not happen. Name the actual shape and why it cannot be used here. * UN-4017 [FIX] Guard the in-backend execution path too, not just the internal API handle_prompt_output_update has two callers, and the first commit only covered one. prompt_studio_helper._handle_response is the other: it feeds response["output"] straight in, and _fetch_single_pass_response dispatches the identical single_pass_extraction executor, so it receives the identical shapes. Guarding only the worker -> internal API route left this path able to 500 exactly as before — and the stated point of the boundary check was that no executor can 500 the backend this way. _handle_response is the shared choke point for both in-backend callers (single prompt and single pass), so the guard goes there. Raises the local AnswerFetchError with 422, matching the source-side guard rather than the generic 500 that class defaults to. The single-pass advice about prompts asking for lists is only emitted when is_single_pass, since that interaction does not exist on the single-prompt path and would misdirect there. * UN-4017 [FIX] Guard metadata too, and fix two tests that proved less than claimed Review found the comment's central claim was false. `metadata` reaches handle_prompt_output_update unchecked and is indexed five times at its lines 135-139 — context, challenge_data, highlight_data, confidence_data, word_confidence_data — all unconditional and evaluated BEFORE the `if not prompts` early exit and before the outputs.get at 179. So a caller posting valid `outputs` with `"metadata": []` still produced the exact 500 this PR claims to eliminate. Both fields are now checked. test_helper_is_never_reached_for_invalid_outputs passed either way: with the guard removed, filter() raised on the fake prompt id before the helper was reached, and patching `status` hid the resulting ValidationError. Patch the ORM instead, and assert the 400. The docstring's "no pytest-django in this repo yet" note was copy-pasted from test_task_status.py and is wrong in both places: pytest-django>=4.12.0 is declared at backend/pyproject.toml:79, backend/conftest.py already imports django.test, and the unit-backend rig group collects this file today. `prompt_ids` is deliberately still unchecked: a non-list makes filter() raise ValidationError which the existing except already turns into a 500, the producer always sends a list, and the `if not prompt_ids` check covers the realistic cases. Noting it so the omission is a decision, not an oversight. * UN-4017 [FIX] Declare json-repair in the workers test group json_repair_helper imports json_repair, but nothing in OSS declares it — the image only gets it transitively, from cloud plugin dependencies that copy_cloud_deps merges into requirements.txt. So the OSS test environment is the one place it is absent, and the helper silently takes `except ImportError: return json_str` there. That makes any test asserting real repair behaviour worse than useless: it goes green while pinning the fallback. Verified — with json_repair blocked, prose and object-plus-prose come back as str and are still rejected, and a clean object takes the json.loads fast path, so three of four such tests pass for the wrong reason. Test group only. Declaring it as a runtime dependency of workers is a separate question, tracked with the broader parser work. * Commit uv.lock changes * UN-4017 [FIX] Address review: in-backend message, metadata guard, lockfile revert Three points from @pk-zipstack. The in-backend detail hardcoded "LLM returned a JSON array" while interpolating the real type, so `response["output"] = None` produced "LLM returned a JSON array ... (got NoneType)" — self-contradictory, and it sends the reader looking for an array that was never there. The cloud half of this ticket makes exactly that argument and pins it with a test; this half did the opposite. Names the type now, no shape claim. `metadata` was still passed unchecked three lines below the guard, and handle_prompt_output_update indexes it five times before its `if not prompts` early exit — the same exposure this PR fixes in internal_views and describes as "missed on the first pass". Both fields are checked here now. Reverts the root uv.lock. The "Commit uv.lock changes" automation regenerated it with an older uv, rolling `revision = 3` back to `1` and stripping every upload-time field: ~2100 lines of pure churn, no dependency, version or hash actually changed. Nothing in this PR touches a root or backend/ dependency — the json-repair addition is in workers/ and lands in workers/uv.lock. Left as it was, it would silently roll the lock format back for everyone and conflict with any other in-flight lock change. * Commit uv.lock changes * Commit uv.lock changes * UN-4017 [MISC] Drop the json-repair declaration and the lockfile churn from this PR Two reasons converge on removing it. The uv-lock automation (.github/workflows/uv-lock-automation.yaml) fires on any `**/pyproject.toml` change and installs uv 0.6.14, which is older than whatever wrote the repo's root uv.lock at revision 3. It regenerates every lock, rolling that one back to revision 1 and stripping ~1070 upload-time fields. It re-runs on every synchronize, so reverting by hand cannot stick while this PR touches a pyproject — @pk-zipstack's request to revert is only achievable by not triggering it. And the declaration does not belong here. This PR is two guards; the json-repair dependency question — test group vs runtime, and the live OSS consequence @pk-zipstack identified at answer_prompt.py:438 — is the subject of #2250, which already declares it. Consequence, stated plainly: the cloud PR's real-repair tests keep skipping in CI until #2250 lands, exactly as they do today. That is visible in the test report rather than silent. * UN-4017 [FIX] Only offer the prompt advice for outputs, never for metadata Regression I introduced when merging the two fields into one loop, and the same class of misdirection the shape-claim rewrite had just removed. `metadata` is executor-assembled — run_id, file_name, context, plus the highlight/confidence blocks — not LLM output. A non-dict there is our defect, so "Rephrase that prompt to describe the value of its own field" is a dead end: no wording change can affect it. The neutral message in internal_views had this right; this path regressed relative to it. Gated on the field. Test asserts the advice is absent for metadata, alongside the existing one asserting it is absent for single-prompt — the field dimension was untested, which is why generalising into a loop slipped past. * UN-4017 [FIX] Declare json-repair so the real-repair tests run on this PR alone These two PRs need to stand on their own, so the dependency comes back here rather than being deferred. json_repair_helper imports json_repair, and nothing in OSS declares it — the image only receives it transitively, from cloud plugin dependencies that copy_cloud_deps merges into requirements.txt. The rig syncs the workers `test` group instead, so json_repair is absent there and the helper silently takes `except ImportError: return json_str`. That is what made the cloud PR's TestAgainstRealRepairOutput skip. Left alone, the suite that exists precisely because stubbing everything hid the original bad split would ship inert. Test group only, and a lower bound rather than a pin. Note on the lockfile: the uv-lock automation regenerates every lock with an older uv on any pyproject change, which rewrites the root uv.lock. Its trigger is `**/pyproject.toml`, so a follow-up commit touching only uv.lock reverts that churn without re-firing it. * Commit uv.lock changes * UN-4017 [MISC] Revert the automation's root uv.lock churn The uv-lock automation installs uv 0.6.14, older than whatever wrote the root lock at revision 3, so it rolls the format back to revision 1 and strips every upload-time field — ~2100 lines, with no dependency, version or hash actually changing. This PR alters no root or backend dependency; its json-repair addition is in workers/ and lands in workers/uv.lock. Reverting held last time only until the next push. This commit touches no pyproject, and the workflow's trigger is `**/pyproject.toml`, so it will not re-fire and the revert stands. The automation does this to every PR that touches any pyproject. Pinning its uv to the version that produced revision 3 would fix it at the source. * Commit uv.lock changes * UN-4017 [MISC] Drop the json-repair declaration and the root lockfile churn Keeps this PR to the two guards. The dependency cannot be declared here without cost: GitHub matches the uv-lock automation's `paths` filter against the whole PR diff, not the individual push, so any PR containing a pyproject change re-fires it on every sync — and that workflow installs uv 0.6.14, older than whatever wrote the root uv.lock at revision 3, so it rewrites ~2100 lines with no dependency, version or hash actually changing. Reverting it cannot stick while the declaration is present; it came back on both attempts. Consequence, stated plainly: TestAgainstRealRepairOutput in the paired cloud PR skips wherever json_repair is absent, which includes the rig. It runs in the image, where cloud plugin dependencies supply json-repair transitively, and locally with the plugin installed. That is visible in the test report rather than silent. The underlying gap is unchanged and independent of these PRs: OSS declares json_repair nowhere, so answer_prompt.py:438-442 persists {} for any answer that is not strictly valid JSON. Worth its own fix, along with pinning that workflow's uv.
PreviousNext