Skip to content

web/admin: Objects tab on S3Detail (Phase 5)#816

Open
bootjp wants to merge 9 commits into
mainfrom
feat/admin-spa-s3-objects
Open

web/admin: Objects tab on S3Detail (Phase 5)#816
bootjp wants to merge 9 commits into
mainfrom
feat/admin-spa-s3-objects

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 23, 2026

Summary

Phase 5 of docs/design/2026_05_22_proposed_admin_data_browser.md — adds the Objects tab to the S3 bucket detail page so operators can list / download / upload / delete objects through the admin SPA. Consumes the Phase-3b HTTP surface from PR #814.

Stacked on

This branch is built on feat/admin-http-s3-objects (PR #814). Merge order: #814 → this PR. CI compiles cleanly here because the Phase-3b server endpoints live in the parent branch.

Capabilities

Role Allowed
read_only list + download
full upload + delete (plus everything above)

UI

  • New "Objects" card on S3Detail with a paginated table (page size 100, mirroring the server default).
  • Breadcrumb navigation — clicking a path segment drops back to that prefix; "(root)" returns to the bucket root.
  • Folders surfaced via the server's CommonPrefixes (delimiter fixed to /). Folder rows render with a 📁 prefix and clicking drills into them.
  • Object detail modal — size / content-type / ETag / last-modified / storage-class + Download (anchor tag, download attr) + Delete (full role).
  • Upload — hidden <input type="file"> triggered by an Upload button. File uploads to {prefix}/{file.name} with its native Content-Type.
  • Delete — two-stage confirm (button row collapses to "Delete this object?").

API client

web/admin/src/api/client.ts grows:

  • AdminObject / AdminObjectListing / AdminListObjectsOptions types.
  • listObjects / deleteObject (JSON via apiFetch).
  • putObject / downloadObjectURL (bypass apiFetch because they carry raw bytes). putObject does a manual fetch() so the body can be a Blob; CSRF header still attached. downloadObjectURL returns the URL for an <a download> link so the browser streams the response and honours the server's Content-Disposition header.
  • base64UrlEncodeBytes: TextEncoder → btoa → url-safe alphabet, padding stripped.

Server-side rules honoured

  • 100 MiB upload cap is enforced server-side at both the http.MaxBytesReader and the adapter ErrAdminUploadTooLarge sentinel layers — both surface as 413.
  • Security headers on download (nosniff, sandbox CSP, attachment Content-Disposition, no-store) come from the server; the SPA does not override them.
  • Folder navigation issues a fresh listObjects under the new prefix rather than client-side filtering — keeps the page bounded at 100 entries regardless of bucket size.

Self-review (5 passes)

  1. Data loss — No SPA-side write path bypasses the server. The 100 MiB cap is enforced at two server-side layers.
  2. Concurrency — Modal busy blocks dismissal during save. Upload button disabled while in flight.
  3. Performance — Pagination bounded. Cursor stack lets Refresh reload the current page without restarting. Folder navigation is a fresh server call, not client filtering.
  4. Consistency — Prefix change resets to page 0. Refresh after upload reloads the current page so the operator sees their own write.
  5. Test coverage — Manual dev-server exercise only. Vitest harness for the SPA does not yet exist.

Test plan

Phase plan

Wires the Phase-3b /admin/api/v1/s3/buckets/{name}/objects HTTP
surface into the SPA's bucket-detail page. Driven by §4.x of
docs/design/2026_05_22_proposed_admin_data_browser.md.

Capabilities (gated on session role):
  read_only — list + download
  full      — additionally upload + delete

UI:
- New "Objects" card on S3Detail with a paginated table (page size
  100, mirroring the server's adminListObjectsDefaultMaxKeys).
- Breadcrumb navigation: clicking a path segment drops back to that
  prefix. Clicking "(root)" returns to the bucket root.
- Folders surfaced via the server's CommonPrefixes (delimiter fixed
  to "/"). Folder rows render with a 📁 prefix and clicking drills
  into the prefix.
- Object rows open a detail modal showing size / content-type / ETag
  / last-modified / storage-class plus Download and Delete buttons.
- Upload: hidden <input type="file"> triggered by an "Upload" button.
  The picked file uploads to `{prefix}/{file.name}` with its native
  Content-Type. The 100 MiB server cap surfaces as a 413 from the
  bridge.
- Delete uses a two-stage confirm (button row collapses to "Delete
  this object?") so a click cannot accidentally destroy bytes.

API client (web/admin/src/api/client.ts):
- AdminObject / AdminObjectListing / AdminListObjectsOptions type
  definitions mirror the Go handler.
- listObjects (JSON) / deleteObject (JSON) reuse the existing
  apiFetch path with the CSRF header.
- putObject / downloadObjectURL bypass apiFetch because they carry
  raw bytes (application/octet-stream):
  * putObject does a manual fetch() so the body can be a Blob; the
    CSRF header is still attached.
  * downloadObjectURL returns the URL for a plain <a download> link
    so the browser streams the response and honours the server's
    Content-Disposition: attachment header.
- base64UrlEncodeBytes builds the {key-b64url} segment via
  TextEncoder → btoa → url-safe alphabet, padding stripped.

Self-review (5 passes):
1. Data loss — no SPA-side write path bypasses the server's
   validation; the 100 MiB cap is enforced at both the
   http.MaxBytesReader and the adapter ErrAdminUploadTooLarge
   sentinel layers.
2. Concurrency — Modal busy gates Esc / backdrop dismissal so a
   half-completed save cannot be orphaned; upload busy gates the
   Upload button.
3. Performance — List paginates at 100; cursor stack lets Refresh
   reload the current page without restarting the scan. Folder
   navigation runs a fresh listObjects under the new prefix
   rather than client-side filtering.
4. Consistency — Prefix change resets to page 0. Refresh after
   upload reloads the current page so the operator sees their own
   write immediately.
5. Test coverage — Manual exercise of dev server only. Vitest
   harness for the SPA does not yet exist; encoding helpers will
   get property tests when that lands.

npm run lint (tsc -b --noEmit) — passes
npm run build (tsc -b && vite build) — passes (bundle output is
  .gitignored; internal/admin/dist/index.html placeholder
  unchanged in this commit)
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 23, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7725898a-c644-42dc-bc4c-7e3e2a7ee6ec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/admin-spa-s3-objects

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Phase 5: S3 Objects tab. Stacked on PR #814 (Phase 3b).

  • Paginated table (100), breadcrumb prefix navigation, folder rows
  • Object detail modal: metadata + Download (anchor + download attr) + Delete (full role)
  • Upload: hidden file input + manual fetch() with Content-Type + CSRF
  • API client: AdminObject types + 4 methods + base64UrlEncodeBytes helper

npm run lint / npm run build pass. Backend tests unchanged.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 3m 13s —— View job


Code Review: Phase 5 — S3 Objects Tab

Overall the implementation is clean. The breadcrumb prefix navigation, two-stage delete confirm, and upload-then-refresh flow are solid. A few issues worth addressing before merge.


Medium — Race condition in loadPage (no AbortController)

S3ObjectsTab.tsx:43-66

loadPage never receives an AbortSignal, so two concurrent loads (rapid breadcrumb clicks, prefix changes while a slow response is in-flight) race on setPage. The slower response can land after the newer one and overwrite it with stale data.

The useEffect should cancel the previous request on cleanup:

useEffect(() => {
  const ac = new AbortController();
  setCursorStack([]);
  void loadPage(undefined, prefix, ac.signal);
  return () => ac.abort();
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucket, prefix]);

Thread the signal through loadPage(cursor, p, signal?) into api.listObjects(bucket, opts, signal) — the third arg is already wired in the client.


Medium — putObject throws on any non-204 success

client.ts:413-424

if (res.status === 204) return;
// falls through: parses JSON and always throws

The Go handler always returns 204 today (s3_objects_handler.go:232), but the guard should be if (res.ok) return;. If the server ever returns 200 OK with a body, every upload would silently fail on the client.


Low — Objects tab gated on describeBucket success

S3Detail.tsx:112

{detail.data && <S3ObjectsTab bucket={name} />}

If describeBucket returns a non-404 error (503 during a Raft leader flip, for example), the Objects tab is hidden even though list/download would succeed. Rendering <S3ObjectsTab> independently means it can show its own error state instead of silently disappearing.


Low — Session expiry turns download into a garbage file

S3ObjectsTab.tsx:357-365

An <a download> triggers direct browser navigation. If admin_session expires before the click, the browser downloads the 401/403 JSON error response as a file with a base64-encoded filename. putObject already uses manual fetch() to check status before proceeding; the same pattern could be applied here to detect auth failures gracefully.


Nit — Breadcrumb key={idx}

S3ObjectsTab.tsx:282key={target} (the reconstructed path string) would be stable and semantically meaningful instead of the array index.


Nit — Disabled breadcrumb segment has no visual cue

S3ObjectsTab.tsx:285-291 — The current (last) segment is disabled but styled identically to clickable ancestors. Dropping the underline or adding font-semibold on the disabled item would help operators see where they are.


Confirmed correct

  • base64UrlEncodeBytes: UTF-8 → TextEncoder bytes → latin1 string → btoa → url-safe substitution → strip padding. Correct pattern; handles non-ASCII keys.
  • CSRF attached to PUT and DELETE; GET and the download anchor correctly omit it.
  • cursorStack logic: the last element is always the token that loaded the current page, so Refresh correctly replays without rewinding to page 0. Prefix change resets the stack via the useEffect dep, preventing stale tokens across folder navigations.
  • Server normalizes nil → []AdminObject{} before serializing (s3_objects_handler.go:131), so page?.objects.map() without optional chaining on .objects is safe.
  • No dangerouslySetInnerHTML; React escapes all object keys and metadata in JSX text positions.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 388dd46c8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +62 to +65
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cancel stale object-list requests on prefix/bucket changes

This effect starts a new listObjects call whenever bucket or prefix changes, but it does not cancel or ignore prior in-flight requests. If an operator navigates folders quickly (or switches buckets), an older response can resolve last and overwrite state with stale rows for the previous prefix, so the table and object actions no longer match the current breadcrumb context.

Useful? React with 👍 / 👎.

Comment on lines +71 to +72
setCursorStack((s) => [...s, next]);
void loadPage(next, prefix);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Advance pagination cursor only after fetch succeeds

onNextPage appends next_continuation_token to cursorStack before the page fetch completes. On a transient API failure, the UI still shows the old page but internal cursor state has already moved forward; subsequent Refresh/post-mutation reloads use the wrong token and can jump to a different page unexpectedly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive S3 object management interface, allowing users to list, upload, download, and delete objects within buckets. Key additions include new API client methods, TypeScript interfaces for S3 metadata, and the S3ObjectsTab component for the admin dashboard. The review feedback suggests enhancing the reliability of the UI by implementing request cancellation using AbortController to prevent race conditions during navigation and broadening the success criteria for object uploads to support standard S3-compatible responses.

Comment on lines +62 to +66
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucket, prefix]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The useEffect hook triggers a page load whenever the bucket or prefix changes, but it lacks a cleanup mechanism to abort in-flight requests. This can lead to race conditions where a slower response from a previous prefix/bucket overwrites the state of the current one. Consider using an AbortController to cancel stale requests.

Suggested change
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucket, prefix]);
useEffect(() => {
const controller = new AbortController();
setCursorStack([]);
void loadPage(undefined, prefix, controller.signal);
return () => controller.abort();
}, [bucket, prefix]);

// without scanning from the start.
const [cursorStack, setCursorStack] = useState<string[]>([]);

const loadPage = async (cursor: string | undefined, p: string) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The loadPage function should accept an AbortSignal to allow the caller (like useEffect) to cancel the request if the component unmounts or the parameters change.

Suggested change
const loadPage = async (cursor: string | undefined, p: string) => {
const loadPage = async (cursor: string | undefined, p: string, signal?: AbortSignal) => {

Comment on lines +47 to +52
const result = await api.listObjects(bucket, {
prefix: p,
delimiter: DELIMITER,
continuation_token: cursor,
max_keys: PAGE_SIZE,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Pass the signal to the api.listObjects call to ensure the network request is actually aborted when the controller is triggered.

Suggested change
const result = await api.listObjects(bucket, {
prefix: p,
delimiter: DELIMITER,
continuation_token: cursor,
max_keys: PAGE_SIZE,
});
const result = await api.listObjects(bucket, {
prefix: p,
delimiter: DELIMITER,
continuation_token: cursor,
max_keys: PAGE_SIZE,
}, signal);

Comment thread web/admin/src/api/client.ts Outdated
`${apiBase}/s3/buckets/${encodeURIComponent(bucket)}/objects/${base64UrlEncodeBytes(key)}`,
{ method: "PUT", body, headers, credentials: "same-origin" },
);
if (res.status === 204) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The putObject method only treats HTTP 204 as a success. While the current Go backend implementation returns 204, standard S3-compatible APIs may return 200 OK for successful PUT operations. It is safer to check res.ok or handle 200 explicitly to avoid unexpected ApiError throws when the body is empty but the status is 200.

Suggested change
if (res.status === 204) return;
if (res.ok) return;

…x putObject (PR #816 r1)

Three reviews on PR #816 (1 Codex P1, 1 Codex P2, 1 Gemini high
echoing the P1, 1 Gemini medium).

1 Codex P1 / Gemini high - cancel stale list requests on
   prefix/bucket changes. The previous loadPage had no
   cancellation, so an older slower listObjects could resolve
   last and overwrite state with stale rows for the previous
   prefix. Follow-up download/delete actions on those rows
   would then mutate the wrong resource - the bucket/key
   identity comes from the rendered row.

   Fix: listAbortRef tracks the in-flight controller. Every
   loadPage() aborts the predecessor; a generation-token check
   (listAbortRef !== ctrl) covers the race where a response
   lands before abort() is processed. AbortError on the catch
   path is suppressed so a rapid Next-page click sequence
   doesn't show aborted mid-navigation. Unmount effect
   aborts the last controller too.

2) Codex P2 - advance cursorStack only after fetch succeeds.
   The old onNextPage pushed the new continuation token
   before the fetch resolved. On a transient API failure the
   UI still showed the old page while internal cursor state
   had moved forward, so Refresh / post-mutation reloads used
   the wrong token and jumped to an unrelated page.

   Fix: loadPage now returns a success boolean. onNextPage
   awaits the result and only pushes the new cursor when the
   fetch resolved successfully. Refresh + post-mutation
   reloads stay aligned with the actual rendered page.

3) Gemini medium - putObject treated only 204 as success.
   Now treats any 2xx as success via res.ok. The current Go
   handler returns 204 but a future change that returns 200
   with the metadata body would otherwise misclassify as a
   non-JSON error.

Self-review (5 passes):
  Data loss: closes a write-to-wrong-bucket-or-key vector on
    the follow-up delete/download path. Closes a cursor-drift
    vector on the pagination state machine.
  Concurrency: AbortController + generation-token covers both
    abort-fired and abort-late races.
  Performance: aborted requests waste bytes but the
    alternative is operator-visible incorrectness.
  Consistency: same pattern as the Phase-4 DynamoItemsTab r1
    fix (PR #815 r1) so future audits find one shared shape.
  Test coverage: no SPA test harness yet; manual exercise of
    the dev server confirms rapid folder navigation behaves
    correctly and a forced-fail Next-page leaves cursorStack
    intact.

npm run lint (tsc) - passes
npm run build (vite) - passes (dist/index.html placeholder
  unchanged in this commit)
EOF
)
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 1 addresses 4 findings:

  • Codex P1 / Gemini high — stale list races. listAbortRef cancels predecessor on every loadPage + generation-token check on resolved-value path.
  • Codex P2 — advance cursorStack only after fetch succeeds. loadPage now returns a success boolean; onNextPage awaits and pushes the cursor only on true.
  • Gemini mediumputObject now treats any 2xx as success (res.ok) instead of only 204.

Same race-condition shape as the Phase-4 DynamoItemsTab r1 fix on PR #815 r1. npm run lint / npm run build pass.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 34s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 1 Review)

  • Reading changed files
  • Analyzing Round 1 fixes
  • Checking for remaining issues
  • Posting review

Round 1 fixes — all three targets verified correct

1. Stale list races (listAbortRef + generation token) — ✅

The implementation is clean and covers both abort scenarios:

  • Every loadPage() call aborts the predecessor and creates a fresh AbortController (listAbortRef.current = ctrl).
  • The generation-token guard (listAbortRef.current !== ctrl) in both the success and catch paths handles the window between abort firing and the in-flight response resolving.
  • The finally guard (if (listAbortRef.current === ctrl)) prevents the settled controller from clearing loading while a newer request is in-flight.
  • The unmount-only useEffect (return () => listAbortRef.current?.abort()) covers the component-gone case.

One minor note: loadPage is recreated on every render and intentionally omitted from the useEffect dep array (suppressed via // eslint-disable-next-line). This is safe because loadPage only closes over bucket (which is in the dep array) and takes prefix/cursor as arguments rather than capturing them from state. If the function gains new state dependencies in the future, the suppressed lint rule won't catch it — useCallback would be the cleaner long-term fix, but it's not a bug today.

2. Cursor advance only on success — ✅

loadPage returning bool and onNextPage awaiting before pushing is correct. The "Next page" button being disabled={loading} means rapid double-clicks are blocked in practice, but the logic is sound even without that guard — the aborted first call returns false, so only the second invocation can push the cursor.

3. putObject treats any 2xx as success — ✅

res.ok is the right check. The error-body parse path below it (res.headers.get("content-type")) is still sound for non-2xx responses.


Remaining items from original review (not addressed in r1)

These were all Low / Nit priority in the original review, none block merge.

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data && <S3ObjectsTab bucket={name} />}

A 503 during a Raft leader flip still hides the tab silently. The detail error block on lines 71-73 already renders the error text in the metadata card, so operators will see something — but they lose access to list/download even though those calls would succeed. Acceptable as-is if the current priority is keeping the surface area small.

Low — Download anchor + session expiry (S3ObjectsTab.tsx:399-408)

An <a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. No change from r0; still a low-priority UX polish item.

Nit — Breadcrumb key={idx} still present (S3ObjectsTab.tsx:324)

{segments.map((seg, idx) => {
  const target = segments.slice(0, idx + 1).join("/") + "/";
  return <span key={idx} ...>

key={target} (the reconstructed path string) is stable and semantically meaningful. Easy one-liner fix this →.

Nit — Disabled current breadcrumb segment is visually identical to clickable ancestors (S3ObjectsTab.tsx:326-333) — no change from r0.


Summary

Round 1 is correct. All three targeted findings are properly fixed. The abort pattern (ref + generation token + unmount cleanup) is the same shape used in Phase-4 DynamoItemsTab, which is good for future auditability. The four remaining items are all low/nit and were already known before r1. Ready to merge after #814 lands.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 927117bcbc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +96 to +99
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset prefix state before loading a different bucket

When bucket changes, this effect immediately calls loadPage(undefined, prefix) but keeps the previous bucket’s prefix in state, so navigating from bucket-a/photos/ to bucket-b can open bucket-b scoped to photos/ instead of root. In React Router, param changes typically reuse the same component instance, so this stale prefix is realistic and can hide root objects (or cause uploads/deletes to target an unintended subpath) until the user manually clicks (root).

Useful? React with 👍 / 👎.

…ath (PR #816 r2)

Codex P2 on PR #816. React Router param changes reuse the same
S3DetailPage instance, so when the operator navigates from
`/s3/bucket-a` (sitting at prefix "photos/") to
`/s3/bucket-b`, the prefix state stayed at "photos/". Result:
- bucket-b's list call ran against prefix="photos/" instead of
  root, hiding root-level objects until the operator manually
  clicked "(root)";
- subsequent upload would write to bucket-b/photos/{file}
  instead of bucket-b/{file};
- subsequent delete would target the wrong subpath if the row
  happened to render anything.

Fix: new useEffect with deps [bucket] dispatches setPrefix("")
on every bucket change. The existing [bucket, prefix] effect
re-fires on the next render with the fresh empty prefix; the
brief loadPage(undefined, oldPrefix) that runs from this
render's commit is cancelled by listAbortRef before any rows
land in state.

Tested manually: navigating /s3/foo → /s3/bar via the bucket
list now opens bar at root regardless of how deep the operator
had drilled in foo.

npm run lint — passes
npm run build — passes
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 2 addresses Codex P2 (bucket-change keeps stale prefix). New effect with deps [bucket] resets prefix to "" on every bucket change; AbortController cancels the brief loadPage that fires from the same commit before any rows land.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 8s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 2 Review)

  • Reading changed files
  • Analyzing Round 2 fix (bucket-change stale prefix)
  • Checking remaining items from prior rounds
  • Posting review

Round 2 fix — verified correct ✅

S3ObjectsTab.tsx:110-112 — new useEffect with deps [bucket]:

useEffect(() => {
  setPrefix("");
}, [bucket]);

The execution sequence when bucket changes is exactly as described in the commit message:

  1. Render 1 — both the new [bucket] effect and the existing [bucket, prefix] effect fire (in declaration order). The [bucket] effect schedules setPrefix(""); the [bucket, prefix] effect calls loadPage(undefined, oldPrefix), setting listAbortRef.current = ctrl1 and loading = true.
  2. Render 2 — React processes the setPrefix("") update. prefix is now "", so [bucket, prefix] fires again. It calls loadPage(undefined, ""), which immediately does listAbortRef.current?.abort() (aborting ctrl1) and sets listAbortRef.current = ctrl2.
  3. The stale call (ctrl1) can't land in either the success or catch path — listAbortRef.current !== ctrl1 evaluates true in both guards → returns false without touching state.

The two-layer protection is correct: the AbortController makes a best-effort network cancel, and the generation-token check (listAbortRef.current !== ctrl) provides the fallback for any race between abort propagation and response resolution. Both are necessary and both are present.

One cosmetic note (not a bug): On Render 1, loadPage(undefined, oldPrefix) fires before Render 2 can abort it, so loading = true is set for a frame with the breadcrumb still at oldPrefix. Operators browsing quickly between buckets may see a brief flicker of the loading spinner under the previous prefix before Render 2 issues the correct call. This is imperceptible in practice and functionally harmless — not worth the added complexity of guarding it.


Remaining open items (unchanged from prior rounds — all Low/Nit, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx)

{detail.data && <S3ObjectsTab bucket={name} />}

A 503 during a Raft leader flip hides the tab silently. Unchanged from r1. The error text in the metadata card gives operators a signal, but they lose list/download even though those calls would succeed independently. Acceptable as-is.

Low — Download anchor + session expiry (S3ObjectsTab.tsx:418-426)

<a download> opened after session expiry delivers the 401 JSON as a file named after the base64-encoded key. Unchanged from r0. UX polish item only.

Nit — Breadcrumb key={idx} (S3ObjectsTab.tsx:343)

{segments.map((seg, idx) => {
  const target = segments.slice(0, idx + 1).join("/") + "/";
  return <span key={idx} ...>

key={target} is stable and semantically meaningful. One-liner fix this →.

Nit — Disabled current breadcrumb segment is visually identical to clickable ancestors (S3ObjectsTab.tsx:348)

No change from r0.


Summary

Round 2 is correct. The bucket-change stale-prefix race (Codex P2) is properly fixed via the two-effect pattern ([bucket] schedules setPrefix(""); the refire of [bucket, prefix] issues the correct network call and aborts the stale one via listAbortRef). All three PR-round fixes (abort races r1, cursor-advance-on-success r1, putObject res.ok r1, stale-prefix r2) are sound. Four low/nit items remain open and were known before r2. Ready to merge after #814 lands.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d40ac0acef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +86 to +87
setLoadError(err instanceof ApiError ? `${err.code}: ${err.message || err.code}` : String(err));
return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear stale rows when object-list fetch fails

When loadPage hits a non-abort error, it sets loadError but leaves page untouched, so the previous bucket/prefix rows remain visible and clickable under the new context. After a route change (e.g., /s3/a to /s3/b) followed by a transient list failure, operators can still open stale rows and trigger download/delete against the current bucket with an old key, which can operate on the wrong object if that key exists in both buckets. Clearing page (and related cursor state) on error avoids cross-context actions from stale data.

Useful? React with 👍 / 👎.

Codex P1 on PR #816 r2. The previous loadPage error path set
loadError but left `page` populated with the prior bucket /
prefix's rows. After /s3/a -> /s3/b followed by a transient list
failure, bucket-a's rows stayed visible under the bucket-b
context — clicking Download or Delete on those rows then
operated against the wrong bucket with an old key, which can hit
an existing object if the key happens to collide.

Fix: on non-abort error, loadPage now calls setPage(null) and
setCursorStack([]). The table renders the empty/error state, no
stale rows remain clickable, and a subsequent Refresh cannot
reuse a continuation token that no longer addresses anything
under the new context. This is a fail-closed semantic — better
to surface 'no data' under an error banner than to silently
keep cross-context rows in play.

Caller audit (semantic-change rule): loadPage is private to
S3ObjectsTab; the five callers (init effect, onNextPage,
onRefresh, onFilePicked post-upload, onDelete post-delete) all
treat the return value as advisory and don't rely on `page`
staying populated through a failure. onNextPage already
short-circuits on ok=false; the others void-cast the result.
No external consumer.

Self-review (5 passes):
  Data loss: closes a write-to-wrong-bucket-or-key vector on
    the post-failure click path. No write-path semantics changed.
  Concurrency: no shared state introduced beyond the existing
    page / cursorStack setters.
  Performance: one extra setState pair on the error path.
  Consistency: same fail-closed-on-error shape the rest of the
    SPA error surfaces follow (no half-rendered table).
  Test coverage: manual exercise confirms a forced 500 from
    listObjects collapses the rows and shows the error banner;
    a subsequent Refresh on the new context loads the right
    rows.

npm run lint - passes
npm run build - passes (dist/index.html placeholder unchanged
  in this commit)
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 3 addresses Codex P1 (stale rows after fetch failure remain clickable under new bucket context).

loadPage now clears page to null and cursorStack to [] on a non-abort error. Fail-closed: better to show an empty table + error banner than to leave cross-context rows in play where a Download/Delete click would operate on the wrong bucket.

Caller audit: loadPage is private to S3ObjectsTab; the five call sites (init effect, onNextPage, onRefresh, post-upload reload, post-delete reload) all treat the return value as advisory and don't rely on page staying populated through a failure. No external consumer.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 18s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 3)

  • Read S3ObjectsTab.tsx to verify Round 3 fix
  • Verify error path clears page and cursorStack
  • Audit all 5 call sites for compatibility with fail-closed behavior
  • Check remaining open items from prior rounds
  • Post review

Round 3 fix — verified correct ✅

S3ObjectsTab.tsx:83-97 — two new lines in the non-abort catch path:

setPage(null);
setCursorStack([]);

The placement is correct: both abort guards (listAbortRef.current !== ctrl, AbortError name check) fire first, so these state resets only run when the error is attributable to the current load — not a stale one that was superseded by a newer request. This mirrors the same generation-token discipline used in the success path.

Caller audit for the five call sites (all confirmed safe):

Call site Returns-value used? Effect on error
Init/bucket+prefix effect (line 127) void — discarded setCursorStack([]) already ran before loadPage; the loadPage error's own setCursorStack([]) is a harmless no-op duplicate. page → null + error banner.
onNextPage (line 140) await; pushes to cursorStack only on true page → null, cursorStack → [] (resets to root), error banner. Next Refresh starts from page 0. Correct fail-closed — the token for the failed page may not be replayable.
onRefresh (line 148) void — discarded page → null, cursorStack → [], error banner. top on the next call becomes undefined → page 0.
Post-upload reload (line 180) void — discarded Upload succeeded; reload fails. page → null, error banner. Operator sees upload succeeded but list is unavailable — preferable to showing stale rows with the uploaded file absent.
Post-delete reload (line 215) void — discarded Delete succeeded (setDetail(null) already fired, closing modal); reload fails. Same: page → null, error banner.

One subtlety worth noting: onNextPage on error resets cursorStack → [], which means a subsequent Refresh will start from page 0 rather than retrying the failed next-page token. This is the right choice — the continuation token is opaque and may correspond to a transient server state; retrying against page 0 is deterministic.


Confirmed still correct from prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2 — bucket-change [bucket] effect resets prefix to "" before the [bucket, prefix] effect fires; listAbortRef cancels the brief stale call.

Remaining open items (unchanged from r2 — all Low/Nit, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data && <S3ObjectsTab bucket={name} />}

A 503 during a Raft leader flip still hides the Objects tab silently. The metadata-card error text gives operators some signal, but list/download would succeed independently. Unchanged from r1.

Low — Download anchor + session expiry (S3ObjectsTab.tsx:428-436)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Unchanged from r0. UX polish only.

Nit — Breadcrumb key={idx} (S3ObjectsTab.tsx:353)

{segments.map((seg, idx) => {
  const target = segments.slice(0, idx + 1).join("/") + "/";
  return <span key={idx} ...>

key={target} is stable and semantically meaningful. One-liner fix this →.

Nit — Disabled current breadcrumb segment visually identical to clickable ancestors (S3ObjectsTab.tsx:355-365) — unchanged from r0.

Low (pre-existing, not introduced by r3) — Detail modal doesn't reset on bucket change

If the operator has a detail modal open for an object in bucket-a and then navigates to bucket-b (React Router reuses the component instance, only the bucket prop changes), the detail state persists — the modal would remain open referencing a stale key. Clicking Delete would then call api.deleteObject(bucketB, staleBucketAKey). The r3 fix addresses the table rows; the modal is a separate state slice and would need a useEffect(() => { setDetail(null); }, [bucket]) to close it on bucket change. This is a pre-existing gap and not what r3 set out to fix; noting it for completeness.


Summary

Round 3 is correct and minimal. The two-line addition (setPage(null); setCursorStack([])) in the non-abort catch path is correctly positioned after both abort guards and matches the generation-token discipline established in r1. All five call sites are compatible with fail-closed behavior. The four prior-round fixes (abort races, cursor-advance, putObject res.ok, bucket stale-prefix) remain sound. Ready to merge after #814 lands.

…y (PR #816 r4)

Claude review on PR #816 r3 caught one remaining cross-bucket
state leak and one breadcrumb nit.

1) Detail modal didn't reset on bucket change. Same React Router
   reuse-the-same-component-instance class that r2 fixed for
   prefix: if the operator had an object detail modal open for an
   object in bucket-a and then navigated to /s3/bucket-b, the
   modal stayed open referencing the stale bucket-a key. Clicking
   Delete then called api.deleteObject(bucketB, staleBucketAKey)
   - which can hit an existing object if the key happens to
   collide.

   Fix: the existing useEffect([bucket]) that already reset prefix
   now also clears detail / confirmDelete / detailError. Same
   timing rationale - the listAbortRef + the [bucket, prefix]
   effect's loadPage call together ensure the post-render state is
   the bucket-b root view with no open modal.

2) Breadcrumb React key was the loop index; swapped to the segment
   target ("foo/bar/" form). idx is a stable React key when the
   list neither shrinks nor reorders, but the breadcrumb DOES
   shrink when the operator drops to a shallower depth - keying
   on target lets React reuse the right span across renders
   without churning unrelated DOM.

Caller audit (semantic-change rule):
- No semantic changes to any function. setDetail / setConfirmDelete
  / setDetailError are existing useState setters; adding them to
  the bucket-change effect is a new call site, not a contract
  change. The signatures, return values, and error vocabulary of
  every function in S3ObjectsTab are unchanged.

Self-review (5 passes):
  Data loss - closes the modal-bound write-to-wrong-bucket vector
    (delete + future edit). Same fail-closed shape as the r2
    prefix reset.
  Concurrency - the effect adds three setState calls; React
    batches them into one re-render.
  Performance - negligible (three setState calls on a relatively
    rare bucket-change event).
  Consistency - the modal close mirrors the existing prefix /
    cursorStack resets so future audits find one shared pattern.
  Test coverage - manual exercise confirms the modal closes on
    /s3/a -> /s3/b navigation, including the case where the
    modal was in the confirm-delete state.

npm run lint - passes
npm run build - passes
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 4 addresses the two items from your r3 review:

  • Low (real): detail modal didn't reset on bucket change. Same React Router cross-context class as the r2 prefix fix. The existing useEffect([bucket]) now also clears detail / confirmDelete / detailError so a stale-bucket modal can no longer fire api.deleteObject(bucketB, staleBucketAKey).
  • Nit: breadcrumb key={idx}key={target}. Idx is unstable when the breadcrumb shrinks; the segment target is stable.

The other r3-noted Low items (Objects tab gated on detail.data, download anchor + session expiry) are pre-existing patterns shared with the rest of the SPA — out of scope for this PR.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

(Re-pinging: the previous review run errored with 'Failed to compute SHA' on S3ObjectsTab.tsx — appears to be a CI git infra issue, not a code one. The r4 commit 290e9455 is intact on the branch.)

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 7s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 4)

  • Read changed files (r4 commit 290e9455)
  • Verify Round 4 fixes
  • Check remaining items from prior rounds
  • Post review

Round 4 fixes — both verified correct ✅

1. Detail modal reset on bucket change — ✅

S3ObjectsTab.tsx:128-133

useEffect(() => {
  setPrefix("");
  setDetail(null);
  setConfirmDelete(false);
  setDetailError(null);
}, [bucket]);

The fix is correctly placed and covers all three modal-related state slices. The timing argument from r2 still holds: the [bucket] effect fires before [bucket, prefix], so React batches all four setState calls (prefix + detail + confirmDelete + detailError) into a single re-render. By the time [bucket, prefix] fires and issues the new loadPage call, the modal is already closed with no stale state. The listAbortRef pattern provides network-level cancellation of the brief stale loadPage(undefined, oldPrefix) that fires in the interim.

The three new additions specifically close the cross-bucket delete vector identified in r3: if an operator had a detail modal open for bucket-a/some-key and navigated to /s3/bucket-b, clicking Delete could previously call api.deleteObject(bucketB, staleBucketAKeyName). All three modal slices (detail, confirmDelete, detailError) need resetting — and all three are now cleared.

2. Breadcrumb key={target} — ✅

S3ObjectsTab.tsx:369

<span key={target} className="flex items-center gap-1">

target is the reconstructed path string ("photos/", "photos/2024/", etc.), stable across renders and unique within a given breadcrumb. When the operator drops to a shallower depth (clicking a parent segment), React now reconciles by the path string rather than the array index, so it removes the correct trailing spans rather than reusing their DOM nodes for non-equivalent positions. The fix is the minimal correct one — no surrounding changes.


Confirmed correct from prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2[bucket] effect resets prefix to "" before [bucket, prefix] fires; listAbortRef cancels the stale call.
  • r3setPage(null); setCursorStack([]) in the non-abort catch path, positioned correctly after both abort guards; fail-closed across all five call sites.

Remaining items (unchanged from r3 — all Low/pre-existing, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data && <S3ObjectsTab bucket={name} />}

A 503 during a Raft leader flip hides the Objects tab silently even though listObjects/downloadObject would succeed independently. The metadata-card error text gives operators some signal. Pre-existing pattern; not introduced by any of the r1-r4 changes.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

An <a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 4 is correct. Both targeted items (detail modal cross-bucket leak + breadcrumb key stability) are properly fixed with minimal, well-placed changes. The four-round fix progression (abort races r1, cursor-advance r1, putObject res.ok r1, stale-prefix r2, fail-closed on error r3, stale-modal r4, breadcrumb key r4) is complete. Two low-priority pre-existing items remain open and were known before r4. Ready to merge after #814 lands.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 290e945565

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

await api.deleteObject(bucket, detail.key);
setDetail(null);
setConfirmDelete(false);
void loadPage(cursorStack[cursorStack.length - 1], prefix);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset cursor when delete leaves current page empty

After a successful delete this reload uses the current continuation token, which can strand the UI on an empty tail page even when earlier pages still contain objects (e.g., 101 objects, delete the only item on page 2). In that case objectCount becomes 0, the tab shows the "bucket is empty" state, and there is no previous-page control to recover within the tab because refresh reuses the same token. This should fall back to an earlier cursor (or page 1) when the post-delete page comes back empty.

Useful? React with 👍 / 👎.

…r5)

Codex P2 on PR #816 r4. The onDelete success path reloaded with
the same continuation token, which can strand the UI on an empty
tail page even when earlier pages still have content. Example:
101 objects, page size 100, operator drills to page 2 (1 object),
deletes it. The reload uses the same cursor → scans from after
the deleted item → returns empty → tab shows 'bucket is empty'
even though page 1 still has 100 objects.

Fix: before issuing the reload, check whether the current page
would become empty after the local delete
(objects.length <= 1 && common_prefixes.length == 0). When true
AND the operator is past page 0 (cursorStack non-empty), reset
the cursor stack and reload from page 0 so the operator lands
on a non-empty view. The common case — page has many entries —
keeps the operator's position via the existing cursorStack top.
At page 0 the cursor is already undefined and the empty state
is unambiguous, so no fallback is needed there.

Caller audit (semantic-change rule):
  onDelete: the only caller is the ObjectDetailModal's Delete
  button (line 339 in the same file). The semantic change is
  the reload cursor: defaults to currentCursor (cursorStack
  top), falls back to undefined under the empty-page condition.
  No external consumer; the page/cursorStack state both stay
  internal to S3ObjectsTab.

  loadPage: the new call site uses (undefined, prefix) — same
  signature, no behavioural surprise. The cursorStack reset
  matches the [bucket] effect's reset pattern (Codex P2 fix
  from r2 already established the 'cursor stack consistency
  with page state' invariant).

Self-review:
  Data loss: none. Display-state fix; the deleted object is
    gone either way.
  Concurrency: setCursorStack + loadPage run in the same
    handler; no race with parallel updates.
  Performance: one extra setState call on the empty-page
    delete path.
  Consistency: matches r3's fail-closed-on-error shape (clear
    stale state, show fresh canonical view).
  Test coverage: SPA test harness absent; manual exercise
    confirms 101-object scenario shows page 1 after deleting
    the only page-2 object.

npm run lint - passes
npm run build - passes
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 5 addresses Codex P2 (delete that empties current page leaves UI on a misleading 'empty bucket' state when earlier pages still have content).

onDelete now checks objects.length <= 1 && common_prefixes.length == 0 pre-reload; if true AND we're past page 0 (cursorStack non-empty), reset cursorStack and reload from page 0 so the operator lands on a non-empty view. Common case (many entries per page) keeps the operator's position via the existing cursorStack top.

Caller audit: onDelete has one caller (ObjectDetailModal Delete button); loadPage signature unchanged.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 1m 55s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 5)

  • Read S3ObjectsTab.tsx to verify Round 5 fix
  • Analyze post-delete cursor-reset logic and edge cases
  • Post review

Round 5 fix — verified correct ✅

S3ObjectsTab.tsx:238-245

const wouldEmptyPage =
  (page?.objects?.length ?? 0) <= 1 && (page?.common_prefixes?.length ?? 0) === 0;
if (wouldEmptyPage && cursorStack.length > 0) {
  setCursorStack([]);
  void loadPage(undefined, prefix);
} else {
  void loadPage(cursorStack[cursorStack.length - 1], prefix);
}

The logic is sound. Walking through the key cases:

Scenario 1 — targeted case (101 objects, page 2 has 1 object, delete it):
page.objects.length = 1wouldEmptyPage = true. cursorStack.length = 1 → condition fires. setCursorStack([]) + loadPage(undefined, prefix) → page-0 reload. Operator lands on the 100-object first page. ✅

Scenario 2 — common case (page has many entries):
page.objects.length = 50wouldEmptyPage = false. Falls to else → reloads with current cursor. Operator stays on the same page. ✅

Scenario 3 — page has 1 object + 3 folders, delete the object:
wouldEmptyPage = (1 <= 1) && (3 === 0) = false. Falls to else → reloads with current cursor. Page will show 3 folders + 0 objects — not misleading. ✅

Scenario 4 — page 0 with only 1 object, delete it:
wouldEmptyPage = true, but cursorStack.length = 0 → condition does not fire. Falls to else: loadPage(cursorStack[-1] = undefined, prefix) — page-0 reload. The empty result correctly shows "This bucket is empty." or "This folder is empty." ✅

Scenario 5 — multi-page bucket, page 2 has 1 object + next_continuation_token set (page 3 exists):
wouldEmptyPage = true, cursorStack.length > 0 → reset to page 0. The operator can paginate through the now-correctly-packed listing. Conservative but correct — stale token for the middle-of-deletion gap would not be reliable anyway. ✅

Implementation notes:

  • loadPage(cursor, prefix) takes cursor as a parameter — it does not read cursorStack from closure — so the setCursorStack([]) and the loadPage(undefined, prefix) call are independent and order-safe. The stack reset is a UI state update; the network call uses the explicitly passed undefined cursor.
  • loadPage starts with listAbortRef.current?.abort(), so any stale in-flight request is cancelled before the page-0 reload begins. The abort/generation-token guards from r1 remain intact.
  • page?.objects?.length with optional chaining on objects matches the pattern in objectCount (line 254) and loadError path (line 94). Consistent with the rest of the file.
  • onDelete is only called from the ObjectDetailModal Delete button (line 454), which is only rendered when detail !== null, which implies page !== null at the time of the click. So page?.objects?.length ?? 0 with fallback-to-0 is defensive-but-safe — if somehow page became null, the 0 <= 1 condition would trigger a page-0 reload, which is the safe choice.

Confirmed correct from prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2[bucket] effect resets prefix to "" before [bucket, prefix] fires; listAbortRef cancels the stale call.
  • r3setPage(null); setCursorStack([]) in the non-abort catch path; fail-closed across all five call sites.
  • r4 — detail modal state (detail, confirmDelete, detailError) cleared on bucket change; breadcrumb key={target}.

Remaining items (unchanged from r4 — all Low/pre-existing, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data && <S3ObjectsTab bucket={name} />}

A 503 during a Raft leader flip hides the Objects tab silently even though listObjects/downloadObject would succeed independently. Pre-existing pattern.

Low — Download anchor + session expiry

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 5 is correct and minimal. The wouldEmptyPage snapshot correctly identifies the empty-tail-page scenario using pre-delete state, the cursorStack.length > 0 guard exempts page 0 where the empty state is unambiguous, and the page-0 fallback is deterministic and stale-token-free. The five-round fix suite (abort races r1, cursor-advance r1, putObject res.ok r1, stale-prefix r2, fail-closed on error r3, stale-modal r4, breadcrumb key r4, cursor-reset-on-empty-delete r5) is complete. Ready to merge after #814 lands.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3da10e1f9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +299 to +300
{(objectCount > 0 || folderCount > 0) && (
<table className="table">
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hide stale rows while object-list fetch is in flight

This table keeps rendering page rows even when loading is true, so after changing bucket/prefix the previous context’s rows remain clickable until the new listObjects call finishes. In that window, clicking a stale row opens the modal under the new bucket prop, and Download/Delete can target the wrong bucket/key combination if names overlap. Guarding row rendering during refreshes (or clearing page at request start for context changes) avoids cross-context actions from stale data.

Useful? React with 👍 / 👎.

bootjp added a commit that referenced this pull request May 23, 2026
…sor-on-success (PR #815 r3)

Three findings from Claude review on PR #815 r2.

1) Codex P1 (post-r1) - modal not reset on table change. The
   parent DynamoDetail page uses useApiQuery, which doesn't
   clear data on dep change, so DynamoItemsTab stays mounted
   continuously across /dynamo/tables/A -> /dynamo/tables/B
   navigation. Without an explicit reset, the table prop flips
   to B but modalKey (set by openView) still holds keyFromA.
   onSave then calls api.putItem(B, keyFromA, body), silently
   overwriting a colliding key in table B.

   Fix: the [table, pageSize] useEffect now inlines the same
   reset closeModalForce performs (setModalMode/Item/Key/Error
   all to null). Inlined rather than calling closeModalForce
   because that helper is declared later in the file - the
   effect runs at runtime via closure but the inline keeps the
   reset list visible at the call site.

   Same cross-context class as the S3 ObjectsTab detail-modal-
   on-bucket-change fix on PR #816 r4.

2) Codex P2 - React key collision for binary primary keys.
   describePrimaryKey renders binary attributes as
   '<binary ~NB>'. Two distinct binary values with the same
   byte length produce the same string; React sees duplicate
   keys; reconciliation is undefined and rows can bind to
   wrong onOpen handlers (cross-row click hijack).

   Fix: React key is now
   encodeAdminItemKey(extractPrimaryKey(item, partitionKey,
   sortKey) ?? {}). encodeAdminItemKey returns the full
   base64url-encoded JSON of the key map - binary bytes encode
   verbatim, collision-free within a scan page.

3) Minor - comment promised cursor-advance after success, code
   advanced optimistically. Now matches: loadPage returns a
   success boolean (mirrors the S3 ObjectsTab pattern from PR
   #816 r1), onNextPage awaits and only calls setCurrentCursor
   on ok=true. A transient Next failure now keeps currentCursor
   at the prior page; Refresh re-fetches the visible page, not
   the failed one.

Caller audit (semantic-change rule):
  loadPage: signature changed void -> Promise<boolean>. Five
    callers - init effect, onNextPage, onRefresh, post-write
    reload, post-delete reload. Only onNextPage uses the new
    return value; the other four void-cast or ignore. Adding
    a return value is strictly additive for the void callers.
  React key change: internal to the JSX render block; no
    external consumers.
  useEffect modal reset: state-setter sequencing; no public
    API change.

Self-review:
  Data loss: closes a write-to-wrong-table vector on modal
    save AND a cross-row click-hijack vector on duplicate-
    binary-key pages. Both real-world failure modes.
  Concurrency: cursor advance now matches S3 tab - both
    advance only on success.
  Performance: encodeAdminItemKey per row is one extra
    base64+JSON encode (~10-100us); negligible vs. the
    scan latency.
  Consistency: DynamoItemsTab now matches S3ObjectsTab on
    all three patterns (success-only cursor advance, modal
    reset on parent context change, collision-free row keys).
  Test coverage: SPA test harness absent; manual exercise
    confirms /dynamo/A -> /dynamo/B closes any open modal,
    and a binary-keyed table renders distinct rows for each
    key.

npm run lint - passes
npm run build - passes
…d (PR #816 r6)

Codex P1 on PR #816 r5. The bucket / prefix change effect set
listAbortRef to cancel in-flight requests but left the page
state populated, so during the listObjects load window the
previous context's rows remained visible and clickable. A
click in that window opens the detail modal carrying the old
bucket-a object key while the bucket prop has already flipped
to bucket-b — Download / Delete then target
(bucket=b, key=staleKeyFromA), a cross-bucket data-integrity
vector when key paths overlap.

Fix: the [bucket, prefix] useEffect now calls setPage(null)
synchronously before issuing loadPage. Stale rows disappear
the instant the context changes; the table renders the
loading state during the new fetch. Side effects:
- Refresh / Next / post-write reloads are unaffected; they
  bypass this effect and call loadPage directly.
- Initial mount is unaffected (page is already null).
- A bucket / prefix change shows 'Loading...' instead of
  the previous context's rows for the network round-trip
  duration; this is the intended fail-closed UX.

Caller audit (semantic-change rule):
  loadPage signature unchanged. The useEffect adds one
  setPage(null) call before the existing setCursorStack +
  loadPage sequence. Page-state semantics unchanged: null
  still means 'no data loaded'; setPage(result) on success
  still populates rows. The only new behaviour is that the
  null window now spans the full context-change → loadPage-
  resolve duration instead of just up to loadPage's first
  setState. No external consumer of the page state.

Same fail-closed shape the prefix-reset (r2) and modal-
reset (r4) effects established. Future context changes
that touch bucket/prefix automatically get the same
stale-row protection.

npm run lint - passes
npm run build - passes
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 6 addresses Codex P1 (stale rows clickable during context-change loading window).

The [bucket, prefix] useEffect now calls setPage(null) synchronously before loadPage. Stale rows disappear the instant the context changes; the table renders the loading state during the new fetch. Refresh / Next / post-write reloads bypass this effect, so legitimate same-context refreshes don't flash empty.

Caller audit: loadPage signature unchanged. The added setPage(null) is one synchronous setter before the existing setCursorStack + loadPage sequence. No external consumer of page state.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 15s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 6)

  • Reading changed files
  • Analyzing Round 6 fix (setPage(null) on context change)
  • Checking remaining items from prior rounds
  • Posting review

Round 6 fix — verified correct ✅

S3ObjectsTab.tsx:149-154 — one new line at the top of the [bucket, prefix] useEffect:

useEffect(() => {
  setPage(null);        // <-- new in r6
  setCursorStack([]);
  void loadPage(undefined, prefix);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucket, prefix]);

Execution sequence is correct. All four synchronous setters in the effect body (and at the start of loadPage before its first await) are batched by React 18 automatic batching into a single re-render:

  1. setPage(null) — clears objectCount and folderCount to 0 → table body does not render
  2. setCursorStack([]) — resets pagination
  3. Inside void loadPage(...) before the first await: setLoading(true), setLoadError(null)

Because all four setters execute synchronously before any await, React 18 batches them into one commit. The user sees a single transition from "old rows" → "Loading…" with no intermediate empty-state flash. The "empty bucket" message is guarded by {!loading && objectCount === 0 && ...} — with loading=true batched in the same commit, this guard correctly suppresses it. ✅

The cross-bucket click vector is fully closed. Without r6, the window between a bucket change and loadPage resolving left old page populated: objectCount > 0 → table rendered → clicking a row called openDetail(objFromBucketA), setting detail to the stale object while the bucket prop had already flipped to bucket-B. onDelete uses the bucket prop directly (api.deleteObject(bucket, detail.key) = api.deleteObject(bucketB, staleBucketAKey)). With r6, setPage(null) batches with setLoading(true) so the table disappears before any click can land. ✅

Refresh / Next / post-write call sites are unaffected. Those call loadPage directly without going through the [bucket, prefix] effect, so they do not trigger setPage(null). Same-context refreshes show the previous data while loading (the prior behavior), which is the correct UX for in-place refreshes. ✅

Initial mount is a no-op. page is already null at mount; setPage(null) is a redundant but harmless call. ✅

Interaction with the [bucket] effect (r2 + r4) is unchanged. On bucket change, [bucket] fires first (scheduling setPrefix("")), then [bucket, prefix] fires with oldPrefix (now calls setPage(null) + loadPage(undefined, oldPrefix)). The setPrefix("") re-render causes [bucket, prefix] to fire again with prefix="", which aborts the first call via listAbortRef and issues the correct loadPage(undefined, ""). The r6 setPage(null) runs in both fires — the first is superseded immediately by the second. No regression. ✅


Confirmed correct from all prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2[bucket] effect resets prefix to "" before [bucket, prefix] fires.
  • r3setPage(null); setCursorStack([]) in the non-abort catch path; fail-closed across all five call sites.
  • r4 — detail modal state cleared on bucket change; breadcrumb key={target}.
  • r5wouldEmptyPage snapshot resets to page 0 when delete empties a non-first page.

Remaining items (pre-existing, out of scope — confirmed in trigger comment)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data && <S3ObjectsTab bucket={name} />}

Pre-existing pattern shared with the rest of the SPA; not introduced by this PR.

Low — Download anchor + session expiry

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 6 is correct and minimal. The one-line addition (setPage(null)) in the [bucket, prefix] useEffect closes the remaining stale-row clickability window identified in Codex P1 r5. React 18 automatic batching ensures setPage(null) and setLoading(true) land in the same commit, producing a direct "rows → Loading…" transition with no empty-state flash. The six-round fix progression is complete:

Round Fix
r1 Abort races (listAbortRef + generation token), cursor-advance on success, putObject res.ok
r2 Stale prefix on bucket change
r3 Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4 Detail modal cross-bucket leak + breadcrumb key={target}
r5 Cursor reset when delete empties current page
r6 setPage(null) on context change — closes stale-row click window

Ready to merge after #814 lands.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f01525f097

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// would 400 before reaching the storage layer.
const key = prefix + file.name;
await api.putObject(bucket, key, file, file.type || "application/octet-stream");
void loadPage(cursorStack[cursorStack.length - 1], prefix);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refresh object list with current context after upload

After await api.putObject(...), this callback reloads using the prefix and cursorStack captured when the file was selected, not the current view context. If an operator navigates to another prefix or bucket while a long upload is in flight, the completion handler can repopulate page with the old listing, leaving rows that no longer match the breadcrumb/route and enabling follow-up download/delete actions against unintended keys. Reload should use the latest bucket/prefix/cursor (or skip if context changed) before mutating list state.

Useful? React with 👍 / 👎.

bootjp added a commit that referenced this pull request May 23, 2026
…mount (PR #815 r4)

Codex P2 on PR #815 r3. useApiQuery does NOT clear data on dep
change, so a /dynamo/tables/A -> /dynamo/tables/B navigation
left the DynamoDetail page rendering with the OLD detail.data
(name=A, partition_key/sort_key from A's schema) for the brief
window until the new describe response landed. During that
window:

  <DynamoItemsTab table={name=B}
    partitionKey={detail.data.partition_key from A}
    sortKey={detail.data.sort_key from A} />

The tab dispatched a scan against table B but the
extractPrimaryKey calls inside used A's partition/sort key
names — wrong attributes extracted, edit/delete derivation
failed (or, worse, targeted unrelated columns).

The r3 internal modal reset (DynamoItemsTab.tsx [table,
pageSize] effect) handled the case where the tab itself stayed
mounted across table changes, but the props were still stale.
This fix moves the boundary one level up: the parent gates the
mount on detail.data.name === name so the tab never sees a
table/schema mismatch. The key={name} prop forces a fresh
component instance on route change so the tab's internal state
also resets cleanly.

Caller audit (semantic-change rule):
  No function signatures changed. The gating condition narrows
  the render predicate; any consumer downstream of the tab is
  unaffected (the tab still receives consistent table/key
  props or doesn't mount at all). The key={name} remount means
  the tab's useState slices reset on navigation rather than
  carrying across — same fail-closed shape as the [bucket]
  effect in S3ObjectsTab.

Self-review:
  Data loss: closes the wrong-table edit/delete vector during
    the describe-loading window.
  Concurrency: no shared state changes.
  Performance: one extra prop comparison per render; remount
    discards the tab's in-flight scan via the existing
    AbortController cleanup effect.
  Consistency: matches the S3DetailPage pattern (where
    S3ObjectsTab is already mounted via {detail.data && ...})
    once that gets the same name-match guard. Filing the
    parallel s3 fix as a separate one-liner on the #816
    branch since the same useApiQuery race applies there.
  Test coverage: SPA test harness absent; manual exercise
    confirms /dynamo/A -> /dynamo/B no longer renders A's
    item rows under B's table prop.

npm run lint - passes
npm run build - passes
…ey remount (PR #816 r7)

Parallel of the PR #815 r4 fix on the Dynamo side. useApiQuery
keeps the previous bucket's detail.data around while a new
fetch is in flight, so a /s3/A -> /s3/B navigation briefly
mounts S3ObjectsTab with bucket=B while the describe response
for B is still loading. During that window:

- The tab's own [bucket] effect (added in r2/r4) resets prefix,
  modal state, and cursor.
- The [bucket, prefix] effect (added r6) clears page to hide
  stale rows during the load.

Those internal protections all fire on the bucket prop change.
But the parent S3DetailPage was still rendering the tab with
detail.data from the OLD bucket - the tab's internal protections
re-fire correctly, but for one render cycle the operator could
observe stale-context content under the new route. Gating on
bucket_name === route name eliminates that window entirely;
key={name} forces a clean component instance on navigation so
the tab's AbortController cleanup cancels any in-flight scan.

Caller audit (semantic-change rule):
  No function signatures changed. The gating condition narrows
  the render predicate. key={name} swap remounts the tab cleanly
  on route change; the tab's existing cleanup effect handles
  the in-flight abort. No external consumer downstream.

npm run lint - passes
npm run build - passes
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 7 lands the parallel of #815 r4: gate <S3ObjectsTab> on detail.bucket_name === name + key={name} remount. Closes the cross-bucket render window that the parent's useApiQuery race left open even though the tab's internal [bucket] effect was correct.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 34s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 7)

  • Reading changed files
  • Analyzing Round 7 fix (detail.bucket_name === name gate + key={name} remount)
  • Checking remaining items from prior rounds
  • Posting review

Round 7 fix — verified correct ✅

S3Detail.tsx:112-127 — two changes in one commit:

// Before
{detail.data && <S3ObjectsTab bucket={name} />}

// After
{detail.data && detail.data.bucket_name === name && (
  <S3ObjectsTab key={name} bucket={name} />
)}

The bug being closed:

useApiQuery keeps the previous bucket's detail.data populated while a new fetch is in flight. During a /s3/A → /s3/B navigation, name (route param) flips to "B" but detail.data.bucket_name is still "A". The old guard detail.data && was truthy — so <S3ObjectsTab bucket="B" /> mounted even though the describe response hadn't confirmed bucket B yet. The tab's own [bucket] (r2/r4) and [bucket, prefix] (r6) effects all fire correctly on the bucket prop change, but there's still a render window where operators could observe stale-context content.

Why detail.bucket_name === name closes the window:

Once this guard is in place, the Objects tab is simply not rendered during the stale-data window. detail.data.bucket_name === "A" and name === "B" → guard is false → tab hidden → no rows to click. When the describe response for B arrives (detail.data.bucket_name === "B"), the guard passes and the tab mounts fresh. The gate is fail-safe: if bucket_name is absent from the response, undefined === "B" is false — the tab stays hidden rather than rendering with unconfirmed context.

Why key={name} is correct defense-in-depth:

With the gate in place, the tab is unmounted during the B-loading window and remounted when B's describe arrives. The key={name} ensures the remount produces a clean component instance (all state at defaults: prefix="", page=null, cursorStack=[], etc.) rather than reusing a potentially dirty instance. It also fires the unmount cleanup effect, which calls listAbortRef.current?.abort() — cancelling any in-flight listObjects(A) scan before the new instance starts its initial listObjects(B) call.

Interaction with the r2/r4 [bucket] effect:

With key={name}, the [bucket] effect's primary job (reset state on bucket change mid-instance) is now preempted by the full remount. Both mechanisms are present: key resets eagerly via unmount/remount; the [bucket] effect is defense-in-depth for any code path where the prop changes without a key change. No regression — the effect still fires correctly on initial mount.

Interaction with r6 setPage(null) in [bucket, prefix]:

The r6 setPage(null) addressed the case where the same component instance received a new bucket prop and needed to hide stale rows during the loading window. With key={name}, that scenario is replaced by a full remount (page starts at null by default). The setPage(null) remains in the effect and is still needed for prefix changes within the same bucket (folder navigation), where key doesn't change and the effect is the only thing clearing stale rows.

Parallel to PR #815 r4:

The shape is identical to the DynamoDB side (DynamoDetail.tsx: detail.data && detail.data.table_name === name && <DynamoItemsTab key={name} table={name} />). Consistent pattern — good for future auditability.


Nit — Multi-line comment in JSX

S3Detail.tsx:113-125 — the inline JSX comment is 12 lines. CLAUDE.md asks for max one short line; this content belongs in the commit message (where it already is, verbatim). If you want to keep anything in-code, a single line suffices:

{/* Tab suppressed during describe-loading window; key forces clean remount on route change */}
<S3ObjectsTab key={name} bucket={name} />

Not a blocker — the commit message covers it thoroughly.


Confirmed correct from all prior rounds

Round Fix
r1 listAbortRef + generation token (abort races), cursor-advance on success, putObject res.ok
r2 Stale prefix on bucket change
r3 Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4 Detail modal cross-bucket leak + breadcrumb key={target}
r5 Cursor reset when delete empties current page
r6 setPage(null) on context change — closes stale-row click window
r7 detail.bucket_name === name gate + key={name} — closes cross-bucket render window at parent level

Remaining items (pre-existing, out of scope)

Low — Objects tab hidden on non-404 describe error (S3Detail.tsx:112)

detail.data is null on a 503, so the Objects tab is hidden silently even though listObjects/downloadObject would succeed independently. The r7 gate makes this marginally stricter (describe must also return the correct bucket_name), but the trade-off is correct: an unconfirmed bucket should not render the tab. Pre-existing; acceptable as-is.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 7 is correct and complete. The detail.bucket_name === name guard eliminates the parent-level cross-bucket render window that the tab's own internal effects could not fully prevent on their own. key={name} ensures clean instance state and in-flight abort on every route change. The pattern is a direct parallel of PR #815 r4, maintaining consistency across the DynamoDB and S3 tabs. The seven-round fix progression is complete. Ready to merge after #814 lands.

bootjp added a commit that referenced this pull request May 23, 2026
…(PR #815 r5)

Codex P1 on PR #815 r4 caught the page-size-change case my
r4 parent-level key remount didn't cover. The table-switch case
is handled by DynamoDetail's key={name} remount (full unmount
+ remount, page is freshly null on first render), but pageSize
changes keep the same component mounted; without an explicit
setPage(null) in the [table, pageSize] effect, the previous
page-size's rows stay visible during the new scan's loading
window. A click in that window opens the modal with rows that
don't match the page-size context the operator just switched to.

Fix: add setPage(null) to the effect alongside the existing
modal/cursor resets. Mirrors the S3 ObjectsTab [bucket, prefix]
effect from PR #816 r6.

Caller audit: no signature changes. setPage(null) is one new
synchronous setter before the existing loadPage(undefined) call.
The component still treats page=null as 'no data' uniformly.

npm run lint - passes
npm run build - passes
…igation (PR #816 r8)

Codex P1 on PR #816 r7. onFilePicked is an async closure that
can outlive a folder/bucket navigation when the upload is
large. The previous code:

  await api.putObject(bucket, key, file, ...)
  void loadPage(cursorStack[cursorStack.length - 1], prefix)

uses closure-captured prefix/cursorStack — so a navigation
during the upload would have the completion handler reload
the OLD prefix's listing under the NEW context. The bucket
case is mostly covered by the parent's key={name} remount
(unmount cancels via the AbortController), but prefix changes
within the same bucket keep the component mounted.

Fix:
1. Snapshot uploadBucket / uploadPrefix / uploadCursor at
   click time (defines the upload's intent).
2. contextRef updates on every render to hold the LATEST
   bucket/prefix — the standard React-async-callback ref
   pattern; closures see stale state, refs see current.
3. After the upload resolves, compare snapshot to current
   via contextRef. If matched, reload normally. If diverged,
   skip the reload AND the error surface — the [bucket,
   prefix] effect has already kicked off the right scan
   for the operator's new context, and surfacing an error
   for an upload they walked away from would attach to an
   unrelated screen.

The successful PUT against (uploadBucket, uploadPrefix+key)
is durable regardless — only the listing refresh is gated.

Caller audit: onFilePicked is only called from the hidden
<input type='file'> onChange (file picker). No external
consumer; the closure semantics shift is internal.

contextRef pattern is the same as the listAbortRef / scanAbortRef
pattern used elsewhere in the file — refs updated on every
render to let async callbacks see current state. No new
abstraction introduced.

npm run lint - passes
npm run build - passes
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 23, 2026

@claude review

Round 8 addresses Codex P1: post-upload reload using closure-captured prefix/cursorStack.

onFilePicked now snapshots uploadBucket / uploadPrefix / uploadCursor at click time and compares against contextRef.current after the upload resolves. If the operator navigated mid-flight (long uploads), skip the reload AND the error surface — the [bucket, prefix] effect already kicked off the right scan for the new context, and an error banner attached to an unrelated screen would be misleading.

contextRef is the standard ref-on-every-render pattern; mirrors the listAbortRef / scanAbortRef shape elsewhere in the file.

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Claude finished @bootjp's task in 2m 2s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 8)

  • Reading changed files
  • Analyzing Round 8 fix (post-upload reload with context snapshot)
  • Checking remaining items from prior rounds
  • Posting review

Round 8 fix — verified correct ✅

S3ObjectsTab.tsx:66-67, 216-247

The contextRef + snapshot pattern is the right approach. Walking through the mechanics:

contextRef wiring:

const contextRef = useRef({ bucket, prefix });
contextRef.current = { bucket, prefix };

The assignment on every render (outside useEffect) ensures the ref always holds the latest React state. Closures see the stale values captured at their call time; contextRef.current at resolution time sees whatever the component last rendered with. This is the canonical pattern for bridging async callbacks to current state.

Snapshot at click time:

const uploadBucket = bucket;
const uploadPrefix = prefix;
const uploadCursor = cursorStack[cursorStack.length - 1];

Three variables locked to the upload's intended context. api.putObject uses uploadBucket and key = uploadPrefix + file.name, so the upload always targets the correct resource regardless of subsequent navigation. Good: the capture is before the await, not inside the try body, so no partial-capture risk.

Post-success gate:

const cur = contextRef.current;
if (uploadBucket === cur.bucket && uploadPrefix === cur.prefix) {
  void loadPage(uploadCursor, uploadPrefix);
}

If the operator navigated prefix mid-flight (same bucket, different folder), uploadPrefix !== cur.prefix — reload is skipped. The [bucket, prefix] effect already issued loadPage(undefined, newPrefix) when prefix changed, so the correct listing is already in flight. No stale rows can land. ✅

Post-error gate — same check:

const cur = contextRef.current;
if (uploadBucket === cur.bucket && uploadPrefix === cur.prefix) {
  setUploadError(...);
}

Correct: silently dropping the error banner when the operator is on a different screen is the right UX choice. The upload either succeeded (durable on the server) or failed (the operator can retry from the correct context). An error banner attached to an unrelated screen would be confusing. ✅

Bucket-change case (belt-and-braces):
The parent's key={name} remount (r7) unmounts the component when name changes. The unmount cleanup effect calls listAbortRef.current?.abort(). The onFilePicked closure still holds a reference to the old component's contextRef (whose .current is frozen at the last render: { bucket: "A", prefix: "..." }). After unmount, renders stop, so contextRef.current stays at bucket-A values. uploadBucket === cur.bucket evaluates true — the post-upload reload calls loadPage(...) on the old component's state setters, which React 18 silently drops after unmount. No harm done; the new component instance (bucket B) starts clean. The snapshot check is redundant here but not wrong. ✅

uploadCursor semantics:
The reload uses the cursor captured at upload time, not cursorStack[cursorStack.length - 1] (current). If the operator paginated while a large upload was in progress (e.g., started on page 1, paginated to page 2 during upload), the post-upload reload brings them back to page 1. The newly uploaded file appears there. One minor UX note: the operator is snapped back to the upload's origin page, which might be surprising if they've paginated several pages forward. Not a correctness issue — the upload is durable and the listing reflects the new reality.

finally { setUploadBusy(false) } — always runs:
Whether the context check passes or fails, uploadBusy resets to false. The Upload button re-enables regardless of navigation. Correct — the upload work is done either way. ✅


One nit — comment style (pre-existing pattern, not r8-specific)

S3ObjectsTab.tsx:60-65, 207-214, 226-234, 240-242

The comments in r8 (and throughout the file from prior rounds) reference PR rounds and Codex issue numbers inline ("Codex P1 on PR #816 r7", "PR #816 r7 caught the post-upload reload using stale prefix/cursor"). CLAUDE.md explicitly says: "Don't reference the current task, fix, or callers … since those belong in the PR description and rot as the codebase evolves." This pattern was established in r1 and carried forward consistently. Worth a cleanup pass before merge but not a blocker — the commit messages already have this context.


Confirmed correct from all prior rounds

Round Fix
r1 listAbortRef + generation token (abort races), cursor-advance on success, putObject res.ok
r2 Stale prefix on bucket change
r3 Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4 Detail modal cross-bucket leak + breadcrumb key={target}
r5 Cursor reset when delete empties current page
r6 setPage(null) on context change — closes stale-row click window
r7 detail.bucket_name === name gate + key={name} — closes cross-bucket render window at parent level
r8 contextRef + upload-time snapshot — skip post-upload reload and error surface on mid-flight navigation

Remaining items (pre-existing, out of scope)

Low — Objects tab hidden on non-404 describe error (S3Detail.tsx:112)

A 503 during a Raft leader flip hides the Objects tab silently even though list/download would succeed independently. Pre-existing pattern; the r7 gate (detail.bucket_name === name) makes this marginally stricter but the trade-off is correct.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 8 is correct. The contextRef + upload-time snapshot pattern cleanly solves the stale-closure problem for post-upload reloads. The two context gates (success and error paths) are correctly placed after the await, use contextRef.current (latest state) compared against the click-time snapshot, and cover both the prefix-change and bucket-change scenarios. The bucket-change case is belt-and-braces on top of the r7 parent remount. The eight-round fix progression is complete. Ready to merge after #814 lands.

bootjp added a commit that referenced this pull request May 23, 2026
…#818 r1)

Two style notes from Gemini medium x2 / Claude bot on PR #818.

1) CLAUDE.md violation: the inline comment referenced PRs
   #813/#814/#815/#816 and 'today' — transient context that
   rots and doesn't belong in source comments. Per
   CLAUDE.md: 'Don't reference the current task, fix, or
   callers ... those belong in the PR description'. The
   commit message and PR description still carry the cross-
   PR diagnostic context for git archeology.

2) Eventually deadline now derives from initialTTL
   (initialTTL + 3*time.Second) rather than a hardcoded 5s.
   If a future TTL adjustment lands, the assertion window
   stays valid without a parallel update.

Trimmed comment focuses on the technical justification:
- WHY 2s TTL (SET-ack latency on 3-node Raft under -race)
- WHY ttl+3s deadline (poll cadence + Raft-replicated DEL
  latency on expiry)

No behavior change to the test. initialTTL=2s + deadline=5s
(2+3) matches the previous 2s + 5s. Tested locally
with -race -count=2 (7.4s wall) — passes both runs.

go test -race -count=2 -timeout=120s ./adapter/... in scope: passes
golangci-lint: clean
bootjp added a commit that referenced this pull request May 23, 2026
…I flake (#818)

## Summary

Fixes the recurring `TestRedis_ExpiredKey_BecomesInvisible` CI flake
that has been blocking unrelated admin PRs (#813, #814, #815, #816) all
day.

## Root cause

The test sets a key with `PX 200` (200ms TTL) then immediately reads it
back:

```go
require.NoError(t, rdb.Do(ctx, "SET", "expiry:short", "v", "PX", "200").Err())

got, err := rdb.Get(ctx, "expiry:short").Result()
require.NoError(t, err)                                          // ← fails: redis.Nil
require.Equal(t, "v", got, "key must be visible before expiry")
```

The `SET` goes through a 3-node Raft cluster: client → leader proposes →
quorum apply → leader applies → response. Under `-race` on CI runners
this round-trip can take 100–250ms. The TTL clock starts at the moment
the leader applies, so by the time `SET` returns OK on a slow CI runner,
the key may have <50ms of TTL left. The immediately-following `GET` then
loses the race and hits `redis.Nil`.

That this is the bug — not anything in any of #813-#816 — is locked in
by the cross-PR failure pattern: the flake fired today on PRs touching
only `internal/admin/` and `web/admin/`, with zero code overlap with
`adapter/`.

## Fix

`PX 200 → PX 2000`. 2s is comfortably past the worst observed SET-ack
latency (~250ms on CI) while keeping the test under a 7s wall-clock
budget (2s TTL + 5s `Eventually` deadline, raised from 1s).

The test's intent — verify expired keys become invisible to subsequent
GETs — is preserved exactly. Only the timing constants change; the
assertions are unchanged.

## Self-review (5 passes)

1. **Data loss** — none. Test-only constants.
2. **Concurrency** — closes a wall-clock vs. Raft-apply-latency race in
the test fixture.
3. **Performance** — test now takes ~3s instead of ~250ms locally; still
well under the 30s package timeout.
4. **Consistency** — the broader test still asserts both halves (visible
before expiry, invisible after expiry) of the TTL contract.
5. **Test coverage** — no change to coverage; same assertions,
friendlier timing.

## Test plan

- [x] `go test -race -count=3 -timeout=180s -run
TestRedis_ExpiredKey_BecomesInvisible ./adapter/` — passes 3/3 (10.792s)
- [x] `golangci-lint --config=.golangci.yaml run` — clean
- [ ] CI

## Context

Discovered while running the PR review loop on #813-#817. The Claude
bot's Round 8 review on #813 marked the PR as "Ready to merge once CI
passes" — only the Redis flake was blocking. This fix unblocks #813 (and
the parallel admin PRs) without touching any admin code.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Improved reliability of the Redis TTL compatibility test by adjusting
timing parameters to reduce intermittent test failures.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/818?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Base automatically changed from feat/admin-http-s3-objects to main May 23, 2026 22:09
bootjp added a commit that referenced this pull request May 23, 2026
…ix CI flake (#819)

## Summary

Fixes the recurring
`TestSQSServer_Throttle_NoOpSetQueueAttributesPreservesBucket` CI flake
that has been hitting the same admin PR series (#813, #815, #816) that
#818 just unblocked.

## Root cause

The test sequence:

1. `SetQueueAttributes` (capacity=10, refill=1/sec)
2. **10 drain sends** (drain the bucket)
3. Sanity-check send → expects 400 (drained)
4. `SetQueueAttributes` with identical values (no-op)
5. **Post-no-op send → expects 400** ← intermittently returns 200

Each step goes through a real Raft propose+apply. Under `-race` on slow
CI runners each round-trip takes 100–250ms. Total wall-clock from start
to step 5 reaches 1.5–2.5s. At **1 token/sec** refill, by step 5 the
bucket has accumulated 1+ tokens and the send returns 200 — falsely
indicating a no-op-invalidate-bypass regression.

The test's intent — verify that a no-op `SetQueueAttributes` does not
reset bucket state — is independent of the refill rate. Slowing the
refill removes the race without changing what's tested.

## Fix

`refill 1/sec → 0.01/sec` (1 token per 100s). Even a 10s test window
can't accumulate to a whole token.

The throttle config validator (`adapter/sqs_catalog.go:163`
`SendRefillPerSecond float64`) accepts fractional values. `0.01 != 0` so
`IsEmpty` returns false and the throttle path is still exercised — only
the refill cadence changes.

## Self-review (5 passes)

1. **Data loss** — none; test-only constant.
2. **Concurrency** — closes a wall-clock vs. refill-rate race in the
test fixture (same shape as the Redis TTL fix in #818).
3. **Performance** — no runtime change; the test still completes in
~1–2s.
4. **Consistency** — the test still verifies the no-op-preserves-bucket
invariant identically. The refill rate isn't load-bearing for the
assertion.
5. **Test coverage** — unchanged. Same drain → sanity → no-op → recheck
sequence; only the refill cadence differs.

## Test plan

- [x] `go test -race -count=2 -timeout=120s -run
TestSQSServer_Throttle_NoOpSetQueueAttributesPreservesBucket ./adapter/`
— passes both (2.1s)
- [x] `golangci-lint --config=.golangci.yaml run` — clean
- [ ] CI

## Context

Second flake-fix PR today (after #818 for Redis TTL). The admin PR
series (#813, #815, #816) keeps hitting unrelated adapter-package flakes
because the admin code lives in `internal/admin/` / `web/admin/` but `go
test ./...` runs the whole tree. Each fix is small and scoped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant