Skip to content

DM-55470: Add execution_error to Times Square client schemas - #631

Open
jonathansick wants to merge 13 commits into
mainfrom
tickets/DM-55470
Open

DM-55470: Add execution_error to Times Square client schemas#631
jonathansick wants to merge 13 commits into
mainfrom
tickets/DM-55470

Conversation

@jonathansick

@jonathansick jonathansick commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

  • Adopt the additive Times Square execution_error contract (DM-55470) in @lsst-sqre/times-square-client: a new ExecutionErrorSchema (code, title, message) plus the field on HtmlStatusSchema and HtmlEventSchema.

  • Stay backward-compatible with pre-DM-55470 Times Square deployments — execution_error is optional-nullable and defaults to null, so payloads that omit the key entirely still parse and normalize to null. code is parsed as a plain string rather than an enum for forward compatibility, with the build-time-known values (timeout, jupyter_error, unknown, result_unavailable) exported as the EXECUTION_ERROR_CODES const.

  • Add failed-state mock fixtures (mockExecutionError, mockHtmlStatusFailed, mockHtmlEventFailed) for the later slices that build the terminal UI on top.

  • Treat a reported execution_error as terminal in both data paths. The html-status query option builders derive refetchInterval from the cached status and stop polling once the error is non-null (useHtmlStatus surfaces it as a new executionError field), and subscribeToHtmlEvents now auto-aborts on an event carrying a non-null execution_error as well as on a successful complete event — a failed run reports complete with no html_hash, so the old success-only condition left the SSE stream open forever. The terminal event still reaches onEvent before onComplete fires, and autoAbortOnComplete: false disables both conditions.

  • Give subscribeToHtmlEvents optional bounded-reconnect options (maxReconnectAttempts, reconnectBackoffMs): once the configured number of consecutive connection failures is reached the stream is aborted and a terminal SseConnectionFailedError is reported through onError, so a consumer can drive a connection-failed UI state and a once-only Sentry capture. Both options default to unset, leaving today's unbounded retry behavior unchanged.

  • Add the way out of that terminal state: deletePageHtml / deleteHtmlByUrl issue Times Square's DELETE /v1/pages/{page}/html soft delete, and the package's first react-query mutation (rerunPageMutationOptions, plus the useRerunPage hook) wraps them. On success it invalidates the ['times-square', 'html-status'] key prefix — covering both the htmlStatusForPage and htmlStatusByUrl key shapes — so the cached execution_error is dropped and polling resumes at its normal cadence.

  • Move Squareone's TimesSquareHtmlEventsProvider onto that package transport: the client provider drops its inline HtmlEvent type and direct fetchEventSource usage in favor of subscribeToHtmlEvents + createHtmlEventsUrl with the bounded-reconnect options (5 attempts, 1 s linear backoff), so SSE events are schema-validated in one place. The connectionFailed alert banner and the once-per-subscription Sentry capture (tagged site: times-square-sse) behave as before, now driven by the package's terminal SseConnectionFailedError, and the events context gains an executionError field for the terminal-UI slices to consume. @microsoft/fetch-event-source is no longer an app dependency.

  • Unify both re-run paths on that one mutation: the page panel's Recompute action drops its raw DELETE fetch for useRerunPage's rerunPageAsync, so it too sends a credentialed request and invalidates the html-status cache. The failure alert and its Sentry capture (tagged site: times-square-recompute) are unchanged — the mutation is deliberately Sentry-agnostic, so the app reports from the call site — and the button is now disabled while a request is in flight. No raw DELETE calls remain in the app.

  • Reach the terminal UI state the contract was added for: when useHtmlStatus reports a non-null execution_error, TimesSquareNotebookViewerClient (which serves both the notebook viewer and the GitHub page-panel paths) stops showing an endless "Loading…" and renders a new NotebookExecutionError panel carrying the API's own title and message. Squareone authors no per-code copy of its own — code only selects the panel's icon and accent tone, and a code from a newer Times Square falls back to a generic treatment. The panel's Re-run notebook button soft-deletes the page instance through useRerunPage, targeting the page's html_url with this instance's notebook parameters and display settings; the mutation's invalidation drops the cached failure, so the viewer returns to its loading state, polling resumes, and the next successful render displays the HTML. A failed re-run surfaces an inline message and is captured in Sentry (tagged site: times-square-rerun).

  • Make that whole flow exercisable in dev mode without a live Times Square: the mock htmlstatus and htmlevents routes gain a failure case on the existing ?a= magic-parameter convention (a=3available: false, html_hash: null, and a realistic timeout execution_error; a=1 and a=2 keep their current behavior), and the mock html route answers DELETE with the spec's DeleteHtmlResponse instead of a 405. A mocked re-run is stateful — it clears the instance's cached outcome for 15 s — so the viewer really does drop back to its loading state and resume polling before the failure returns. The dev page-metadata mocks (/v1/pages/:page, /v1/github/:path, PR preview) had drifted from the Page schema the client parses (string description; missing date_added, uploader_username, html_events_url, github), which left the dev notebook viewer stuck on "Loading…" regardless of ?a=; they now share one schema-valid builder.

  • Re-vendor packages/times-square-client/openapi.json at the Times Square 0.25.0 release, the first tagged release carrying the execution_error contract, so the vendored spec documents the API surface these schemas model. The only API-surface change is on htmlstatus: HtmlStatus gains an optional-nullable execution_error backed by the new HtmlExecutionError (code, title, message) and NotebookExecutionErrorCode (timeout, jupyter_error, unknown, result_unavailable) schemas — matching ExecutionErrorSchema and EXECUTION_ERROR_CODES exactly, so no client code changes. The SSE html/events payload is not schema-modeled upstream (the endpoint declares an untyped text/event-stream response), so HtmlEventSchema's execution_error stays client-side only.

  • Stop the page panel from reporting a failed run as a success. Times Square reports a failure as execution_status: 'complete' with a non-null execution_error, so ExecStats kept reading "Computed … in N seconds." in the sidebar while the viewer showed the failure panel. It now reads the events context's executionError and, when it is non-null, renders a failure summary carrying the API's own title (the same verbatim-copy policy as the viewer panel — no Squareone-authored per-code wording), followed by when the run finished and no duration phrasing. The check precedes the completed branch and its dateFinished guard, so a failed run that settles without a finish time still gets a summary, just without the timestamp line. The Recompute button stays — it is the recovery path — and with executionError: null the summary is byte-identical to before.

  • Make that error text actually render red. Every source usage of --rsd-color-red-900 — the new ExecStats failure/error summaries, the SSE connection alert, the token detail page's delete-error and error panels, and squared's TextInput / TextArea / Select error variants — pointed at a custom property rubin-style-dictionary never defines (its red scale ends at 800), so each color declaration was dropped and the text silently inherited the surrounding body color. All nine now use --rsd-color-red-600 ("Dark red for solid backgrounds and text"), which pairs with the red-500 borders and red-100 backgrounds those rules already carry and clears WCAG AA on both white (7.17:1) and red-100 (6.16:1). Adopting the documented token is the fix; adding a red-900 shade is left as a future design-system decision.

Subsequent PRD #620 tasks append to this PR.

Validation steps

  • In a consumer, import HtmlStatusSchema and parse a payload with execution_error present, one with it explicitly null, and one with the key absent — confirm all three parse and the absent case yields execution_error: null.
  • Repeat the same three cases against HtmlEventSchema.
  • Parse an execution_error carrying a code value not in EXECUTION_ERROR_CODES (e.g. some_future_code) and confirm it is accepted rather than rejected.
  • Import mockHtmlStatusFailed / mockHtmlEventFailed from the package root and confirm they reflect the server contract (available: false, html_hash: null).
  • With a Times Square page whose htmlstatus response carries an execution_error, watch the network panel and confirm the htmlstatus request stops repeating once the error arrives.
  • With a page still executing (and again once it renders successfully), confirm htmlstatus keeps polling about once per second, unchanged.
  • With a page whose notebook execution fails, watch the network panel and confirm the html/events SSE connection closes once the failure event arrives instead of staying open, and that the failing event still reached the UI before teardown.
  • With a page that renders successfully, confirm the SSE connection still closes on completion exactly as before.
  • Subscribe with { autoAbortOnComplete: false } and confirm the stream stays open after both a failed and a successful terminal event.
  • Call subscribeToHtmlEvents against an unreachable events URL with { maxReconnectAttempts: 3, reconnectBackoffMs: 1000 } and confirm in devtools that the browser stops retrying after the third failure, with roughly 1 s / 2 s gaps between attempts, and that onError receives a final SseConnectionFailedError.
  • Call it against the same unreachable URL with no reconnect options and confirm retries continue indefinitely at the transport's own interval, exactly as before.
  • From a consumer, call useRerunPage()'s rerunPage({ pageName, params }) on a parameterized page and confirm in the network panel that a single DELETE /times-square/api/v1/pages/<page>/html?<params> goes out carrying that instance's parameters.
  • Call rerunPage({ htmlUrl }) with a fully-formed html_url (the shape ExecStats holds) and confirm the same DELETE is issued against that URL.
  • On a page sitting in the terminal error state, trigger a re-run and confirm the htmlstatus request starts repeating again — the invalidated query refetches, the fresh response carries execution_error: null, and the 1 s cadence resumes.
  • Trigger a re-run against a page name that does not exist and confirm the hook reports isError with a TimesSquareError rather than silently doing nothing.
  • Open a Times Square page with notebook parameters in the URL and confirm the html/events request still carries the full query string (both ts_-prefixed display settings and notebook parameters), and that the exec-stats sidebar fills in as events arrive.
  • Stop the Times Square backend while a page is open and confirm the viewer retries a bounded number of times (~1 s, 2 s, 3 s, 4 s gaps), then shows the "Lost the connection…" banner and stops retrying, with a single Sentry event tagged site: times-square-sse.
  • On a successfully rendered page, click Recompute in the sidebar and confirm exactly one DELETE goes out against the instance's html_url, that it carries credentials, that the button is disabled while it is in flight, and that the notebook re-executes.
  • Make that DELETE fail (stop the backend or return a 500) and confirm the "Failed to request a recompute" alert appears and a single Sentry event tagged site: times-square-recompute is captured; then restore the backend, click Recompute again, and confirm the alert clears on the successful retry.
  • Open a Times Square page whose notebook fails to execute and confirm the viewer shows the error panel with the API's own title and message instead of a perpetual "Loading…", and that the htmlstatus request stops repeating in the network panel.
  • Click Re-run notebook on that panel and confirm a single DELETE .../html?<params> goes out carrying this page instance's notebook parameters and display settings, the viewer drops back to the loading state, htmlstatus polling resumes, and the notebook renders once execution succeeds.
  • Make that re-run request fail (stop the backend or return a 500) and confirm the "Failed to request a re-run" message appears beside the button and a single Sentry event tagged site: times-square-rerun is captured.
  • Against a Times Square deployment predating DM-55470 (responses with no execution_error key), open a page that is still executing and one that renders successfully, and confirm the viewer behaves exactly as it does today.
  • Run pnpm dev --filter squareone, open /times-square/github/lsst-sqre/times-square-demo/demo?a=3, and confirm the execution-error panel renders with the timeout title and message (the page metadata and notebook tree load normally).
  • Click Re-run notebook on that page and confirm a DELETE .../pages/demo/html?a=3&... returns 200, the viewer switches to "Loading…", htmlstatus polling resumes, and the error panel returns about 15 seconds later.
  • Open the same page with no ?a= (success) and with ?a=2 (pending) and confirm the notebook iframe renders and the loading state persists, respectively — unchanged from before.
  • Run pnpm run check-openapi-drift and confirm times-square-client reports in sync (a version-only difference against the latest docs build, which currently serves a dev-versioned spec) rather than DRIFT.
  • Confirm packages/times-square-client/openapi.json reports info.version 0.25.0 and defines execution_error on the HtmlStatus schema.
  • Run pnpm dev --filter squareone, open /times-square/github/lsst-sqre/times-square-demo/demo?a=3, and confirm the sidebar summary reads the failure title and when it failed — not "Computed … in N seconds."
  • Click Recompute in that failed-state sidebar and confirm the request goes out and the viewer drops back to its loading state, exactly as from a successful run.
  • Open the same page with ?a=1 and confirm the sidebar still reads "Computed … in N seconds." and ?a=2 still shows "Computing…".
  • On that ?a=3 page, inspect the sidebar failure summary and the SSE connection alert and confirm the text renders dark red (#ad1919) rather than the default body color.
  • In Storybook (pnpm storybook --filter @lsst-sqre/squared), open the TextInput / TextArea validation-state stories and the Select "With Form Field Error" story and confirm the error variants' text is dark red, matching their red border, instead of ordinary body text.
  • Load a token detail page, trigger a delete failure, and confirm the error panel's text and heading render dark red on the light red background.

References

Adopt the additive DM-55470 `execution_error` contract in
`@lsst-sqre/times-square-client`: a new `ExecutionErrorSchema`, the
field on `HtmlStatusSchema` and `HtmlEventSchema`, and failed-state
mock fixtures for the slices that build the terminal UI on top.

Key decisions:
- `code` is parsed as `z.string()`, not an enum, so codes introduced by
  newer Times Square deployments still validate; the build-time-known
  values ship as the exported `EXECUTION_ERROR_CODES` const plus a
  `KnownExecutionErrorCode` type.
- `execution_error` uses `.nullable().default(null)`, which accepts the
  key present, explicitly null, or absent and normalizes all three to a
  non-optional `ExecutionError | null` on the output type. Consumers
  therefore never see `undefined`, and pre-DM-55470 payloads parse
  unchanged.
- Because the output type is non-optional, every existing `HtmlStatus`
  and `HtmlEvent` mock literal now carries `execution_error: null`.
- Failed fixtures follow the server contract: `available: false` and
  `html_hash: null` alongside a non-null `execution_error`.

Next-iteration notes:
- The changeset `.changeset/times-square-execution-error.md` is the
  package's entry for this branch; later slices should extend that file
  rather than adding a second `@lsst-sqre/times-square-client` entry.
  The app needs its own `squareone` changeset.
- `packages/times-square-client/openapi.json` is still the pre-DM-55470
  vendored spec — re-vendoring is a separate task on this PRD.
- `mockHtmlStatusFailed` / `mockHtmlEventFailed` / `mockExecutionError`
  are exported from the package root, ready for the `useHtmlStatus`
  terminal-polling and SSE abort-on-failure slices.
- Local-only: `.playwright-mcp/*.yml` scratch artifacts make
  `pnpm run prettier:yaml` fail in this working copy. They are
  gitignored and unrelated to this branch.

Closes #621
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2ccb629

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@lsst-sqre/squared Patch
squareone Minor
@lsst-sqre/times-square-client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Extend the Times Square SSE transport with optional `maxReconnectAttempts`
and `reconnectBackoffMs` options so a subscription can stop retrying a
persistently unreachable endpoint, aborting the stream and reporting a
terminal `SseConnectionFailedError` through `onError`. This replicates in
the package the bounded-reconnect behavior PR #610 added inline to
`TimesSquareHtmlEventsProviderClient`, ahead of that provider migrating
onto this transport.

Key decisions:
- The terminal signal is a dedicated `SseConnectionFailedError` subtype
  delivered through the existing `onError` callback (rather than a new
  callback), so a consumer distinguishes it by `instanceof` alongside the
  non-fatal `SseInvalidEventError`. It carries the last underlying error
  as `cause` and the failure count as `attempts`.
- The per-attempt connection `Error` is still reported before the terminal
  error, so default behavior is unchanged and a consumer that throttles
  capture to the first error keeps seeing the raw cause.
- Termination throws from `onerror` after aborting: fetch-event-source
  reschedules a reconnect on any returned value, and only a throw stops
  it. The resulting promise rejection is swallowed via a `connectionFailed`
  flag so the failure is not reported twice.
- The failure counter resets on a successful `onopen`, making
  `maxReconnectAttempts` a bound on *consecutive* failures rather than on
  the subscription's lifetime.
- Both options default to unset: no bounding and no backoff, so the
  transport's existing retry behavior and all existing consumers are
  unaffected.

Next-iteration notes:
- #626 migrates `TimesSquareHtmlEventsProviderClient` onto this transport
  with `{ maxReconnectAttempts: 5, reconnectBackoffMs: 1000 }`, which
  reproduces its current 5-attempt / 1s-linear-backoff behavior; drive
  `connectionFailed` off `error instanceof SseConnectionFailedError` and
  keep the once-per-subscription Sentry capture on the first error.
- Extend `.changeset/times-square-execution-error.md` rather than adding a
  second `@lsst-sqre/times-square-client` changeset entry.

Closes #622
Both html-status query option builders now derive `refetchInterval` from
the cached status, returning `false` once `execution_error` is non-null so
a failed notebook no longer polls Times Square once per second forever.
`useHtmlStatus` surfaces the failure as a new `executionError` field.

Key decisions:
- The interval helper takes a structural `{ state: { data } }` snapshot
  rather than TanStack's `Query`, so one helper serves both builders
  despite their different query key types.
- Recovery is left to cache invalidation: a re-run (later slice)
  invalidates the query, which clears the cached error and resumes the
  unchanged 1 s cadence.
- The new hook test asserts request counts against real timers (~2.5 s
  observation window) instead of fake timers, which react-query's
  interval plumbing and RTL's `waitFor` do not compose cleanly with.

Next-iteration notes:
- `useHtmlStatus` consumers in `apps/squareone` do not yet read
  `executionError`; wiring the failed-state UI is a later slice.
- Changeset entry `.changeset/times-square-execution-error.md` extended
  in place; keep appending there for further client changes.

Closes #623
Extend the auto-abort condition in subscribeToHtmlEvents so a validated
event carrying a non-null execution_error also completes the
subscription. A failed run reports execution_status 'complete' with no
html_hash, so the previous success-only condition left the SSE stream
open indefinitely after a terminal failure.

Key decisions:
- The terminal event is delivered to onEvent before onComplete fires and
  the stream aborts, so a consumer can read the failure off that event
  rather than needing a separate signal.
- autoAbortOnComplete: false disables both terminal conditions (success
  and failure), keeping the opt-out a single all-or-nothing switch.
- Failure is keyed on execution_error being non-null alone, not on
  execution_status, since the status is 'complete' for failed runs and
  the error object is the authoritative terminal signal.

Next-iteration notes:
- None. The changeset .changeset/times-square-execution-error.md gained a
  paragraph for this change; keep extending that same file for further
  @lsst-sqre/times-square-client work on this branch.

Closes #624
Adds the `DELETE /v1/pages/{page}/html` client functions and the package's
first react-query mutation, so a terminal `execution_error` can be cleared
by requesting a fresh notebook execution.

Key decisions:
- Two client call shapes, `deletePageHtml(baseUrl, pageName, params)` and
  `deleteHtmlByUrl(htmlUrl, params)`, mirroring
  `fetchHtmlStatus`/`fetchHtmlStatusByUrl`. ExecStats holds a fully-formed
  `html_url` rather than a page name, so the by-URL variant lets it drop
  its raw `fetch(..., { method: 'DELETE' })` without reconstructing a page
  name and base URL.
- Mutation options live in a new `mutation-options.ts` next to
  `query-options.ts`, and take the `QueryClient` as their first argument
  (a mutation's cache side effects need a client, and the factory has no
  React context to read one from). This is the pattern for future
  mutations in the monorepo.
- `onSuccess` invalidates the whole `['times-square', 'html-status']` key
  prefix rather than one exact key. That covers both `htmlStatusForPage`
  and `htmlStatusByUrl` shapes, which matters because a consumer holding
  an `html_url` cannot reconstruct the exact key of the html-status query
  it needs to refresh. The over-invalidation is bounded — html-status
  queries poll every second anyway — and the alternative (deriving the
  status URL from the html URL by string surgery) is fragile.

Next-iteration notes:
- Consumers should use the `useRerunPage(options?)` hook from
  `@lsst-sqre/times-square-client`: it returns `{ rerunPage,
  rerunPageAsync, isPending, isError, error, reset }`, where `rerunPage`
  takes either `{ pageName, params?, baseUrl? }` or `{ htmlUrl, params? }`
  (the `RerunPageVariables` union). The hook fills in the
  repertoire-discovered base URL, so `baseUrl` is only needed to override.
- #627 (ExecStats Recompute): call `rerunPage({ htmlUrl:
  htmlEvent.htmlUrl })` and drive the existing failure UI from `isError`
  rather than checking `response.ok` by hand.
- #628 (viewer Re-run button): the viewer polls by
  `htmlStatusUrl`, and the mutation's prefix invalidation reaches that key
  shape too, so no extra wiring is needed to resume polling.
- `timesSquareKeys.rerunPage()` is the mutation key if a consumer needs
  `useIsMutating` instead of a local `isPending`.

Closes #625
TimesSquareHtmlEventsProviderClient no longer carries its own SSE
transport: it subscribes through the package's subscribeToHtmlEvents with
the bounded-reconnect options and builds its URL with createHtmlEventsUrl,
so events are schema-validated by the package and the context can expose
the DM-55470 executionError.

Key decisions:
- connectionFailed is driven by the package's terminal
  SseConnectionFailedError; the Sentry capture stays gated on the first
  error of a subscription (the raw connection error, i.e. the wrapper's
  cause) so the terminal signal doesn't produce a second, duplicate event.
- The page query string is turned back into a params record for
  createHtmlEventsUrl. An empty query string now yields a bare URL rather
  than a trailing "?", which is the only URL-shape change.
- MAX_SSE_RECONNECT_ATTEMPTS is no longer exported; the provider tests
  assert the 5-attempt / 1000 ms options passed to the transport instead.
- @microsoft/fetch-event-source is dropped from the squareone app's
  dependencies (the package owns it now). The lockfile edit is surgical —
  only the app importer entry — because a full `pnpm install` here rewrote
  unrelated `libc:` fields; `pnpm install --frozen-lockfile` passes.

Next-iteration notes:
- TimesSquareHtmlEventsContextValue now requires executionError
  (ExecutionError | null); any new fake context value must supply it (the
  dynamic-import loading fallback and ExecStats.test.tsx already do).
- ExecStats.stories.tsx declares its own local HtmlEventContextProps that
  omits connectionFailed/executionError; it type-checks today but should be
  switched to TimesSquareHtmlEventsContextValue if a story needs either.
- #628 consumes executionError from useHtmlStatus, not from this context;
  the context field is for the panel/exec-stats path.

Closes #626
ExecStats no longer issues its own raw `DELETE fetch` against the SSE
event's `html_url`; it calls `useRerunPage`'s `rerunPageAsync` with the
by-URL variables shape, so the panel's Recompute and the execution-error
re-run path share one transport, one credentialed request, and one
html-status cache-invalidation policy. This removes the last raw DELETE
call in the app.

Key decisions:
- Used `rerunPageAsync` in a try/catch rather than an effect on the
  hook's `error`, keeping the Sentry capture (`site:
  'times-square-recompute'`) at the call site and guaranteeing exactly
  one report per failed attempt.
- Dropped the local `recomputeFailed` state in favour of the mutation's
  `isError`; a new attempt resets it, so a successful retry clears the
  alert without any explicit bookkeeping (covered by a new test).
- Called `useRerunPage()` with no options: the by-URL shape never
  consults the discovered base URL, and the hook's discovery query is
  disabled without a repertoire URL, so threading one through would add
  a ConfigProvider dependency for no behavioral gain.
- Disabled the button while the request is in flight, now that pending
  state is available, to prevent overlapping soft-deletes.

Next-iteration notes:
- `ExecStats` now requires a `QueryClientProvider` ancestor; the app has
  one at the root layout and Storybook has one in its preview decorator,
  and `ExecStats.test.tsx` wraps its renders in a per-test QueryClient
  with `mutations: { retry: false }`.
- Package soft-delete responses are schema-parsed, so any new test
  stubbing a successful DELETE must return a JSON body with `html_url`
  and `html_events_url` — a bare 202 with no body now fails.
- `ExecStats.stories.tsx` still declares its local `HtmlEventContextProps`
  omitting `connectionFailed`/`executionError`; untouched here because no
  story needs either field yet.

Closes #627
A notebook that fails to execute no longer leaves the viewer stuck on
"Loading..." forever: when `useHtmlStatus` reports a terminal
`execution_error`, `TimesSquareNotebookViewerClient` renders a new
`NotebookExecutionError` panel carrying the API's own title and message,
with a Re-run button that soft-deletes the page instance through
`useRerunPage` so polling resumes and a subsequent render displays HTML.

Key decisions:
- The panel renders the API's `title`/`message` verbatim and authors no
  per-code copy; `code` only picks the lucide icon and accent tone, with
  an unrecognized code (from a newer Times Square) falling back to the
  generic red treatment rather than rendering nothing.
- Re-ran by URL using the *page metadata's* parameter-free `html_url`
  plus the viewer's own params, mirroring exactly how the same metadata's
  `html_status_url` is parameterized for polling. Using the status
  response's `html_url` instead would have risked duplicated query
  parameters, since the package appends rather than replaces them.
- Mirrored ExecStats' `rerunPageAsync` + try/catch shape so the Sentry
  capture stays at the call site (tagged `site: times-square-rerun`), and
  reused the mutation's own `isError`/`isPending` instead of local state.
- Put `role="alert"` on the panel container (house style, cf.
  TokenCreationErrorDisplay) and left the re-run failure message as a
  plain paragraph inside it, avoiding a nested live region.
- Memoized the params object so the re-run callback keeps a stable
  identity; the query key is hashed structurally, so polling is
  unaffected.

Next-iteration notes:
- `TimesSquareNotebookViewerClient` now needs a `QueryClientProvider`
  ancestor for the mutation as well as the queries; the app root has one
  and the new test supplies its own.
- The test drives the real hooks against a stubbed `global.fetch` routing
  page metadata / htmlstatus / DELETE. Any new case stubbing a successful
  DELETE must return the `DeleteHtmlResponse` JSON body.
- The polling-stop assertion waits 1.5 s of real time (the package's poll
  cadence is 1 s); the legacy-shape test needs a 3 s `findBy` timeout for
  the same reason.
- Dev mock API routes still return the pre-DM-55470 shape, so the failure
  state is not yet exercisable in `pnpm dev` — that remains the dev-route
  task's job.

Closes #628
Extend the development-mode Times Square mocks so the failure → error
panel → re-run → loading flow can be driven without a live Times Square:
`?a=3` now reports a terminal `timeout` execution error from both the
htmlstatus and htmlevents routes, and the mock html route answers DELETE
with the spec's `DeleteHtmlResponse` instead of a 405.

Key decisions:
- A mocked re-run is stateful: the DELETE records the page instance in a
  small in-memory store (`timesSquareExecutionStore`) that reports it as
  re-executing for 15 s, so the viewer really does drop back to its
  loading state and resume polling before the instance settles back to
  its `?a=`-driven outcome. A stateless DELETE would return the error
  immediately and never exercise the loading half of the flow.
- Browser validation surfaced that the dev page-metadata mocks
  (`/v1/pages/:page`, `/v1/github/:path`, PR preview) had drifted from
  the `Page` schema — string `description`, missing `date_added`,
  `uploader_username`, `html_events_url`, `github` — so the
  schema-parsing client rejected them and the dev notebook viewer was
  stuck on "Loading…" regardless of `?a=`. They now share one
  `buildMockPage` builder that emits a schema-valid payload, without
  which none of this task's acceptance criteria are observable.
- Existing magic values keep their behavior: `a=1` succeeds, `a=2` stays
  pending.

Next-iteration notes:
- The re-run window is 15 s (`RERUN_WINDOW_MS`); dev-only, in-memory, and
  reset per server restart.
- The Times Square dev page renders end-to-end again; use
  `/times-square/github/lsst-sqre/times-square-demo/demo?a=3` to see the
  execution-error panel.

Closes #629
Refresh packages/times-square-client/openapi.json from the Times Square
0.25.0 release, the first tagged release carrying the DM-55470
execution_error contract, so the vendored spec documents the API surface
the client's Zod schemas already model.

Key decisions:
- Fetched from the versioned docs URL
  (https://times-square.lsst.io/v/0.25.0/_static/openapi.json) rather
  than via `pnpm fetch-openapi`: the latest docs build currently serves a
  dev-versioned spec (0.24.2.devNN+g...) from a docs-tagging quirk. The
  package's fetch-openapi script is deliberately left unchanged — the
  drift checker derives the live URL from it, and it should keep pointing
  at the latest docs build.
- The 0.25.0 spec is byte-identical to the current latest docs spec apart
  from info.version, which the drift checker excludes from comparison, so
  times-square-client now reports "version-only" (green) instead of
  "drift".
- Only the htmlstatus response changed: HtmlStatus gained an
  optional-nullable execution_error backed by the new HtmlExecutionError
  and NotebookExecutionErrorCode schemas, matching ExecutionErrorSchema
  and EXECUTION_ERROR_CODES exactly. No client code change is needed.
- Extended the existing times-square-execution-error changeset rather
  than adding a new one; the re-vendoring is part of the same
  execution_error adoption already described there.

Next-iteration notes:
- The SSE html/events payload is still not schema-modeled upstream (the
  endpoint declares an untyped text/event-stream response), so
  HtmlEventSchema's execution_error remains client-side only. PRD
  acceptance criterion "execution_error on ... the SSE event schemas"
  cannot be satisfied from the vendored spec.
- `pnpm run check-openapi-drift` still exits 1 on semaphore-client, a
  pre-existing failure unrelated to this task: the committed and live
  specs differ only in the element order of two `examples` arrays
  (["58","57","56","59"] vs ["58","56","57","59"]), which look like a
  serialized Python set. Needs either a re-vendor or an order-insensitive
  comparison for examples arrays; tracked separately.

Closes #630

@jonathansick jonathansick left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stoker review — PR #631 (review pass 1)

Well-structured adoption of the terminal execution_error contract with strong backward-compatibility handling and thorough tests across the package transport, app providers, viewer UI, and dev mocks; verified against the vendored fetch-event-source semantics. One edge-case robustness bug in the error panel's code-to-presentation lookup and two informational nits — nothing blocking.

Findings

  1. [warning / correctness] apps/squareone/src/components/TimesSquareNotebookViewer/NotebookExecutionError.tsx (L45–63): The presentation lookup presentations[executionError.code] ?? genericPresentation uses a plain object literal, so a code equal to an Object.prototype property name (e.g. constructor, toString, valueOf) resolves to the inherited value rather than undefined. The ?? fallback is bypassed, Icon and tone destructure to undefined, and rendering <Icon /> throws — defeating the component's own unknown-code fallback for exactly the forward-compatibility case it documents. The code value is API-supplied, so this is unlikely but real.
    Suggested fix: guard with Object.hasOwn(presentations, executionError.code) (or a null-prototype record / Map), and add a test/story with code constructor to lock in the fallback.

  2. [info / maintainability] apps/squareone/src/lib/mocks/timesSquareExecutionStore.ts (L25–37): mockExecutionError duplicates, verbatim, the fixture the package already exports from @lsst-sqre/times-square-client mock-data (the file even imports the ExecutionError type from the package). The two copies can silently drift.
    Suggested fix: import the package's mockExecutionError instead of redefining it, or intentionally diverge and note why.

  3. [info / compatibility] packages/times-square-client/src/mutation-options.ts (L10–14): mutationOptions is imported from @tanstack/react-query, but that helper was added partway through the v5 line (present in the installed 5.90.20). The package's peerDependencies still declare ^5, so a consumer on an early 5.x would fail at import.
    Suggested fix: verify the release that introduced mutationOptions and raise the peer range floor accordingly (e.g. ^5.83.0).

{
  "stoker_review_version": 1,
  "pr_number": 631,
  "blocking": false,
  "summary": "Well-structured adoption of the terminal execution_error contract with strong backward-compatibility handling and thorough tests across the package transport, app providers, viewer UI, and dev mocks; verified against the vendored fetch-event-source semantics. One edge-case robustness bug in the error panel's code-to-presentation lookup and two informational nits — nothing blocking.",
  "findings": [
    {
      "id": "f1",
      "severity": "warning",
      "category": "correctness",
      "file": "apps/squareone/src/components/TimesSquareNotebookViewer/NotebookExecutionError.tsx",
      "line_start": 45,
      "line_end": 63,
      "summary": "The presentation lookup `presentations[executionError.code] ?? genericPresentation` uses a plain object literal, so a code equal to an Object.prototype property name (e.g. 'constructor', 'toString', 'valueOf') resolves to the inherited value rather than undefined. The `??` fallback is bypassed, `Icon` and `tone` destructure to undefined, and rendering `<Icon />` throws — defeating the component's own unknown-code fallback for exactly the forward-compatibility case it documents. The code value is API-supplied, so this is unlikely but real.",
      "suggested_fix": "Guard the lookup with an own-property check, e.g. `const presentation = Object.hasOwn(presentations, executionError.code) ? presentations[executionError.code] : genericPresentation;`, or build `presentations` as a `Map` / `Object.create(null)`-based record. Consider adding a story/test case with code 'constructor' to lock in the fallback."
    },
    {
      "id": "f2",
      "severity": "info",
      "category": "maintainability",
      "file": "apps/squareone/src/lib/mocks/timesSquareExecutionStore.ts",
      "line_start": 25,
      "line_end": 37,
      "summary": "`mockExecutionError` here duplicates, verbatim, the fixture the package already exports from `@lsst-sqre/times-square-client` mock-data (the file even imports the ExecutionError type from the package). The two copies can silently drift.",
      "suggested_fix": "Import and re-export the package's `mockExecutionError` instead of redefining it, or intentionally diverge the dev-mock copy and note why."
    },
    {
      "id": "f3",
      "severity": "info",
      "category": "compatibility",
      "file": "packages/times-square-client/src/mutation-options.ts",
      "line_start": 10,
      "line_end": 14,
      "summary": "`mutationOptions` is imported from `@tanstack/react-query`, but that helper was only added partway through the v5 line (present in the installed 5.90.20; believed introduced around 5.83 — needs verification). The package's peerDependencies still declare `^5`, so a consumer on an early 5.x would fail at import. Low stakes while squareone (pinned ^5.90.20) is the only consumer.",
      "suggested_fix": "Verify the release that introduced `mutationOptions` and raise the `@tanstack/react-query` peer range floor accordingly (e.g. `^5.83.0`)."
    }
  ]
}

- Guard the NotebookExecutionError presentation lookup with Object.hasOwn so
  an API-supplied code matching an Object.prototype member (constructor,
  toString, ...) takes the generic fallback instead of an inherited value,
  with a unit test locking in the fallback.
- Re-export the package's mockExecutionError fixture from the dev execution
  store instead of duplicating it verbatim.
- Raise the @tanstack/react-query peer floor to ^5.82.0, the first release
  exporting mutationOptions (absent in 5.81.5), and note it in the changeset.

@jonathansick jonathansick left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stoker review — PR #631 (review pass 2, scoped to e8ade4f)

The fixup commit correctly addresses all three findings from the first review: the Object.hasOwn presentation-lookup guard lands with thorough prototype-name tests (constructor, toString, valueOf, __proto__, plus a consistency assertion against ordinary unknown codes); the dev-mock mockExecutionError is deduplicated onto the package's identical fixture export; and the @tanstack/react-query peer floor is raised to ^5.82.0 with a matching devDependency, lockfile entry, and changeset note. No new defects introduced.

{
  "stoker_review_version": 1,
  "pr_number": 631,
  "blocking": false,
  "summary": "The fixup commit correctly addresses all three findings from the first review: the Object.hasOwn presentation-lookup guard lands with thorough prototype-name tests, the dev-mock mockExecutionError is deduplicated onto the package's identical fixture export, and the @tanstack/react-query peer floor is raised to ^5.82.0 with a matching devDependency, lockfile entry, and changeset note. No new defects introduced.",
  "findings": []
}

ExecStats reported a failed notebook run as a success — Times Square
sends `execution_status: 'complete'` with a non-null `execution_error`,
so the sidebar read "Computed … in 14.2 seconds." while the viewer
showed the failure panel. It now reads the events context's
`executionError` and renders a failure summary instead.

Key decisions:
- The `executionError` check precedes the `executionStatus === 'complete'`
  branch, so it is independent of that branch's `dateFinished` guard: a
  failed run that settles without a finish time still gets a summary,
  just without the timestamp line.
- Copy follows NotebookExecutionError's policy — the API's `title` is
  rendered verbatim and Squareone authors no per-code wording. Nothing
  is keyed by `code` here, so no presentation lookup was needed.
- Duration is dropped entirely in the failed state; it describes work
  that never produced a result.
- The summary is plain text, not a live region: the viewer's failure
  panel already announces the same failure as an alert, and a second
  one would double-announce. The `recomputeFailed` alert is untouched.
- ExecStats.stories.tsx now types its args as
  `TimesSquareHtmlEventsContextValue` rather than a hand-rolled subset,
  so a story cannot drift from the context contract.

Next-iteration notes:
- None.

Closes #634

@jonathansick jonathansick left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stoker review — PR #631 (review pass 3, scoped to 6a65631)

The follow-up commit (task #634) is correct and well-tested: the executionError branch precedes the complete branch so a failed run is never reported as computed, the no-finish-time case degrades gracefully, the recompute path is preserved and covered from the failed state, and the stories are now typed against the real context contract. One non-blocking token-hygiene note; mergeable as-is.

Findings

  1. [info / clarity] apps/squareone/src/components/TimesSquareGitHubPagePanel/ExecStats.module.css (L14–19): the new .failure class uses var(--rsd-color-red-900), but that custom property is not defined anywhere in the repo — the rubin-style-dictionary red scale tops out at 800, so the color declaration silently falls back to the inherited text color. This mirrors the pre-existing .error class in the same file and other usages across the codebase (TokenDetailsView.module.css, squared TextInput), so it is an established latent token gap rather than a regression introduced here.
    Suggested follow-up (not required for this PR): either switch these usages to the defined --rsd-color-red-600 ("Dark red for solid backgrounds and text") or add a 900 shade to the red scale in rubin-style-dictionary, then sweep the existing red-900 usages.
{
  "stoker_review_version": 1,
  "pr_number": 631,
  "blocking": false,
  "summary": "The follow-up commit 6a656319 (task #634) is correct and well-tested: the executionError branch precedes the 'complete' branch so a failed run is never reported as computed, the no-finish-time case degrades gracefully, the recompute path is preserved and covered from the failed state, and the stories are now typed against the real context contract. One non-blocking token-hygiene note; mergeable as-is.",
  "findings": [
    {
      "id": "f1",
      "severity": "info",
      "category": "clarity",
      "file": "apps/squareone/src/components/TimesSquareGitHubPagePanel/ExecStats.module.css",
      "line_start": 14,
      "line_end": 19,
      "summary": "The new .failure class uses var(--rsd-color-red-900), but that custom property is not defined anywhere in the repo — the rubin-style-dictionary red scale tops out at 800 (packages/rubin-style-dictionary/src/color/red.yaml), so the color declaration silently falls back to the inherited text color and the failure headline is not actually red. This mirrors the pre-existing .error class in the same file and other usages across the codebase (TokenDetailsView.module.css, squared TextInput), so it is an established latent token gap rather than a regression introduced here.",
      "suggested_fix": "As a follow-up (not required for this PR): either switch these usages to the defined --rsd-color-red-600 (documented as 'Dark red for solid backgrounds and text') or add a 900 shade to the red scale in rubin-style-dictionary, then sweep the existing red-900 usages."
    }
  ]
}

Every source usage of --rsd-color-red-900 pointed at a custom property
the rubin-style-dictionary red scale never defines (it ends at 800), so
each of those color declarations was dropped and error text silently
inherited the surrounding body color. All nine usages now resolve to
--rsd-color-red-600 ("Dark red for solid backgrounds and text").

Key decisions:
- Adopted the existing documented token rather than adding a red-900
  shade to rubin-style-dictionary; a richer red scale is a future
  design-system decision, not a prerequisite for making these styles
  render at all.
- Picked 600 over 700/800 because it is the scale's documented "solid
  backgrounds and text" step and pairs with the red-500 borders and
  red-100 backgrounds these rules already carry. Contrast checks:
  7.17:1 on white and 6.16:1 on red-100, both clearing WCAG AA.
- Confirmed in Storybook that the TextArea error variant computes to
  rgb(173, 25, 25) while --rsd-color-red-900 resolves to the empty
  string, verifying both the bug and the fix.

Next-iteration notes:
- The only remaining "red-900" strings in the repo are in the historical
  planning note .claude/tasks/2025-10-fix-css-variables.md, which is a
  record of past work and not app or package source.
- Built storybook-static artifacts still embed the old value; they are
  generated output and regenerate on the next build.

Closes #635

@jonathansick jonathansick left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stoker review — PR #631 (review pass 4, scoped to 2ccb629)

The red-900 → red-600 sweep (task #635) is complete and correct: all nine source usages of the undefined --rsd-color-red-900 are replaced with the defined --rsd-color-red-600 (#ad1919), a repo-wide grep finds no remaining source references (only the historical planning note and generated storybook-static output), only color declarations were touched (the adjacent red-500 borders and red-100 backgrounds are preserved), and the new @lsst-sqre/squared patch changeset plus the extended app changeset accurately describe the fix. No findings.

{
  "stoker_review_version": 1,
  "pr_number": 631,
  "blocking": false,
  "summary": "The red-900 to red-600 sweep (task #635) is complete and correct: all nine source usages of the undefined --rsd-color-red-900 are replaced with the defined --rsd-color-red-600 (#ad1919), no source references remain, only color declarations were touched (red-500 borders and red-100 backgrounds preserved), and the new squared patch changeset plus the extended app changeset accurately describe the fix.",
  "findings": []
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment