fix: complete web kind discrimination and revive deferred test suites#611
Conversation
8 plans (3 Wave-1 foundation/tests, 5 Wave-2 UI wiring) for web kind-discrimination completion + deferred test revival
- ResolvedChild.createdAt sourced from Node.createdAt in resolveChildren - Same already-unsealed node, no new codec/seal call
- Add createdAt alongside modifiedAt in all ResolvedChild toEqual assertions - Add explicit createdAt wiring assertion proving SC2 SDK foundation - client.test.ts, client-shared-write.test.ts, folder-reresolve.test.ts needed no changes -- their Node fixtures already carried createdAt and none construct ResolvedChild literals directly
- ResolvedChild.createdAt threaded end-to-end in packages/sdk - typecheck and full 411-test vitest suite green
- Return resolvedByIpnsName from useFileBrowserActions so FileBrowser.tsx and dialogs can read the real kind - Replace the five hardcoded-folder itemType stubs fed into renameItem/moveItem/deleteItem/moveItems/deleteItems with isFileRefResolved lookups against resolvedByIpnsName - Fix the shift-select range sort to use folders-first-then-alpha ordering via isFileRefResolved, matching FileList.tsx display order, removing the last deferred-phase marker in the file
Delete the stale TODO suggesting a re-key to Node.id for the folder ID in useFolderNavigation.ts. Folder identity is intentionally keyed by ipnsName across route params, store lookups, and SDK mutation calls -- a prior UUID-keying attempt caused an orphaned-store-entry bug (68.1/68.2-09). This is a documentation-only change with zero code-behavior modification.
grep-verified no consumer reads InviteInfo.itemType -- InviteLinkTab.tsx (the sole caller of fetchInvitesForItem) never destructures or renders it. There is also no ResolvedChild/parent listing in scope at the invite layer to resolve a real kind from. Rather than keep a hardcoded best-effort default with no data source, drop the field entirely and remove the stale deferred-phase marker.
- Replace empty object-spread branch with a populated node/v3 file Node matching BinEntry.nodeRef, using the fixture shape from 79-PATTERNS.md - Update the round-trip field assertion to check nodeRef identity/content fields now that the fixture is populated (fileKey is not asserted since the bin wire form only hex-encodes nodeReadKey, not nested content.fileKey) - Remove the phase 65 deferred marker
…tract REVIVE decision. Read the current load.ts fetchAndDecryptMetadata: it composes fetchFromIpfs then JSON.parse then unsealNode, with no D-13 error-wrapping try-catch (no CID-in-message typed Error exists in the current implementation). The skipped suite mocked the retired decryptFolderMetadata export and asserted a wrapped-error contract that no longer exists. Rewrote the suite to mock unsealNode instead, asserting the actual current behavior: malformed JSON rejects with the raw parse error before unsealNode is reached, a wrong-key rejection propagates unsealNode's own error unchanged, and the happy path returns unsealNode's Node unchanged. Zero .skip, zero deferred marker.
…ionale RETIRE decision. Read the current updateFileMetadata (packages/sdk-core/src/file/index.ts:433): it is single-shot (rebuilds and republishes the Node directly at fileSequenceNumber+1n, no expectedSequenceNumber CAS, no 409 retry, no remote-merge), matching shared-write.ts's updateSharedFile. The quarantined describe.skip mocked retired @cipherbox/core exports (encryptFileMetadata/decryptFileMetadata) and exercised a CAS-retry/conflict-merge loop the current function does not have; un-skipping verbatim would not compile against the current contract. Discovered this is NOT a coverage gap (contrary to the plan's working assumption): packages/sdk-core/src/__tests__/file/file-node.test.ts already has a live, non-skipped 'updateFileMetadata' describe block (Phase 68.1-07) that exercises the CURRENT single-shot contract end-to-end against the real @cipherbox/core codec — sequenceNumber threading, nodeId/generation/originalCreatedAt preservation (verified via a full seal-publish-unseal round-trip), and version-capping via capVersions. The write-side rollback-guard coverage T-79-03 flags is already satisfied there. Removed the dead vi.mock scaffolding and unused imports that only served the retired suite; kept the still-valid mergeVersions suite (and its makeVersion fixture helper) untouched. Zero .skip, zero deferred marker, zero @ts-expect-error/as-any casts.
Records the REVIVE decision for load.test.ts and the RETIRE decision for file.test.ts's updateFileMetadata CAS+conflict suite, with the read-first evidence and rationale for each, per the plan's decision-recording requirement.
- sortItems now sorts folders before files via isFileRefResolved, with in-progress UploadVirtualEntry rows short-circuited to file kind - onDrop and onExternalFileDrop are re-enabled per row, gated on isFileRefResolved so only folder rows accept a drop - toResolvedChildView synthetic fallback now carries createdAt: 0 - FileList passes resolvedByIpnsName down to FileListItem - removes phase 63 deferred markers in this file
- add resolvedByIpnsName map prop to FileListItem - multi-select drag branch now derives each item's real per-item kind via isFileRefResolved instead of a hardcoded 'folder' stub - single-item drag branch uses the already-resolved isFolder value - removes phase 63 deferred markers in this file
- sortItems now sorts folders-first via isFileRefResolved, then localeCompare - thread resolvedByIpnsName into both SharedMoveDialog render sites - remove stale phase 63 deferred markers
- movedFolderIds now filters moved items via isFileRefResolved so a file cannot disable destinations in the cannot-move-into-own-subtree guard - add resolvedByIpnsName prop, fed by SharedFileBrowser - remove stale phase 63 deferred markers
FileBrowser resolves each dialog subject's real kind via isFileRefResolved and passes it into RenameDialog/ConfirmDialog (kind-aware title + delete message), ShareDialog (new kind prop gating the folder slash suffix), and both MoveDialog renders (new resolvedByIpnsName prop). MoveDialog's cannot-move-into-own-subtree cycle guard now filters to folder-kind items so a file cannot impose a phantom cycle constraint. Removes all TODO(phase 63) markers from the three files.
Replace the 'unavailable (phase 63)' Created-row stub in FileDetails and FolderDetails with the same Number.isFinite guard used for the Modified row, rendering formatDate(item.createdAt) from the now-mandatory ResolvedChild.createdAt (Plan 01). Removes the stale FolderDetails read-chain TODO(phase 63) marker.
…dChild The still-loading/listing-miss fallback ResolvedChild literal gains createdAt: 0 next to the existing modifiedAt: 0 so it typechecks under the now-mandatory ResolvedChild.createdAt (Plan 01). A real ResolvedChild from the listing flows through unchanged with its true createdAt.
removeFolder only drops the single keyed entry, orphaning already-loaded descendant FolderNodes when a parent folder is deleted (a stale entry could be hit by useFolderNavigation's isLoaded fast path). Adds collectDescendantFolderIds, a BFS over the store's parentId links, and applies it at both the single-delete and batch-delete sites. Identity stays ipnsName-keyed — parentId walk only, no Node.id re-key. Removes both TODO(phase 63) markers.
Un-skip the moveItemHandler and batchMoveItemsHandler describe blocks (REQ-2/REQ-6) and add createdAt to the ResolvedChild fixture so the file typechecks under the now-mandatory field (Plan 01). Two revived assertions were stale vs the live signature and are corrected (not re-skipped): the vaultKeypair-absent case surfaces 'Not authenticated' (the live message), and the empty-batch case is a vacuous success that still calls clearSelection (no early-return guard). Removes all TODO(phase 65) markers and the two inline phase-65 annotations.
…ry field makeResolvedChild (useSyncPolling.test.ts) and makeChild (folder.store.test.ts) gain createdAt so both files typecheck under the now-mandatory ResolvedChild.createdAt (Plan 01). folder.store.test.ts was not in the plan's files_modified but carries the same compile break; folding its one-line fixture fix here brings apps/web tsc -b fully green (it was the 4th of the 4 pre-existing errors).
…listing test DetailsDialog's listing-miss fallback set createdAt: 0, which the Details Created row rendered as 'January 1, 1970' (Number.isFinite(0) is true) instead of the intended dim '—'. Use Number.NaN so the guard shows the placeholder. folder-listing.test.ts fixed createdAt === modifiedAt, so a modifiedAt to createdAt projection swap in resolveChildren went undetected. Thread a distinct createdAt through buildChildFixture and assert createdAt !== modifiedAt.
|
Warning Review limit reached
Next review available in: 30 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
WalkthroughPhase 79 completes resolved file/folder kind handling across the web browser, adds ChangesPhase 79 tracking and implementation
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR completes web file-kind handling and restores deferred test coverage. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (3): Last reviewed commit: "Merge branch 'main' into feat/web-kind-d..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/src/components/file-browser/SharedMoveDialog.tsx (1)
168-174: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winUse the moved item’s kind in the single-item title.
SharedFileBrowsercan open this dialog for files, but the title remainsMove Folder. Since the new cycle guard explicitly supports file moves, this produces incorrect user-facing text for single-file moves.- const title = isBatchMode ? `Move ${items!.length} items` : 'Move Folder'; + const title = isBatchMode + ? `Move ${items!.length} items` + : item && isFileRefResolved(item, resolvedByIpnsName) + ? 'Move File' + : 'Move Folder';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/file-browser/SharedMoveDialog.tsx` around lines 168 - 174, Update the single-item title logic in SharedMoveDialog’s title declaration to use the moved item’s kind, displaying the appropriate file or folder wording instead of always “Move Folder.” Preserve the existing batch title and label behavior, including the fallback when no item is available.apps/web/src/hooks/useFolderMutations.ts (1)
416-464: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winClean up deleted folders as each batch item succeeds.
handleDeleteItemsdefers store removal until the loop finishes, so a later throw skips cleanup entirely. The SDK event only removes the deleted folder itself, which can leave already-deleted folder subtrees with stale descendants inuseFolderStoreuntil the next refresh. Move subtree removal inline after each successful delete, or add compensating cleanup incatch.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/hooks/useFolderMutations.ts` around lines 416 - 464, Update handleDeleteItems so each successful deleteWithBehavior call immediately removes the corresponding folder and its collected descendants from useFolderStore, rather than deferring all cleanup until the loop completes. Preserve cleanup for file deletions and ensure earlier successful folder deletions remain removed if a later batch item throws.
🧹 Nitpick comments (1)
packages/sdk/src/folder-listing.ts (1)
8-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDoc comment misgroups mandatory fields with "optional".
createdAtandmodifiedAtare non-optional onResolvedChild(onlysizeis?), but the phrasing lists them right after "optional", reading as if all three are optional.✏️ Suggested wording fix
- * optional file size, createdAt, modifiedAt, and the child's own IPNS - * sequence. + * an optional file size, plus the mandatory createdAt, modifiedAt, and the + * child's own IPNS sequence.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sdk/src/folder-listing.ts` around lines 8 - 9, Update the doc comment for ResolvedChild to state that only the file size is optional, while createdAt, modifiedAt, and the child’s own IPNS sequence are mandatory fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
@.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-07-SUMMARY.md:
- Around line 34-37: Update the createdAt sentinel decision entry in the summary
to record Number.NaN instead of 0, and state that this value causes
DetailsDialog to render the placeholder for missing details. Remove the outdated
epoch-rendering claim while preserving the surrounding context about the
existing modifiedAt behavior.
In @.planning/STATE.md:
- Around line 5-11: Update the status field in the phase metadata to complete so
it matches the completed Phase 79 state and the last_activity_desc indicating
all plans were executed and verified.
---
Outside diff comments:
In `@apps/web/src/components/file-browser/SharedMoveDialog.tsx`:
- Around line 168-174: Update the single-item title logic in SharedMoveDialog’s
title declaration to use the moved item’s kind, displaying the appropriate file
or folder wording instead of always “Move Folder.” Preserve the existing batch
title and label behavior, including the fallback when no item is available.
In `@apps/web/src/hooks/useFolderMutations.ts`:
- Around line 416-464: Update handleDeleteItems so each successful
deleteWithBehavior call immediately removes the corresponding folder and its
collected descendants from useFolderStore, rather than deferring all cleanup
until the loop completes. Preserve cleanup for file deletions and ensure earlier
successful folder deletions remain removed if a later batch item throws.
---
Nitpick comments:
In `@packages/sdk/src/folder-listing.ts`:
- Around line 8-9: Update the doc comment for ResolvedChild to state that only
the file size is optional, while createdAt, modifiedAt, and the child’s own IPNS
sequence are mandatory fields.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a6e7701c-8b9f-4bdc-8e9e-2a293f9f9b79
📒 Files selected for processing (46)
.planning/ROADMAP.md.planning/STATE.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-01-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-01-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-02-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-02-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-03-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-03-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-04-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-04-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-05-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-05-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-06-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-06-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-07-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-07-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-08-PLAN.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-08-SUMMARY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-LEARNINGS.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-PATTERNS.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-RESEARCH.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-SECURITY.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-VALIDATION.md.planning/phases/79-web-kind-discrimination-completion-and-deferred-test-revival/79-VERIFICATION.mdapps/web/src/components/file-browser/DetailsDialog.tsxapps/web/src/components/file-browser/FileBrowser.tsxapps/web/src/components/file-browser/FileList.tsxapps/web/src/components/file-browser/FileListItem.tsxapps/web/src/components/file-browser/MoveDialog.tsxapps/web/src/components/file-browser/ShareDialog.tsxapps/web/src/components/file-browser/SharedFileBrowser.tsxapps/web/src/components/file-browser/SharedMoveDialog.tsxapps/web/src/components/file-browser/details/FileDetails.tsxapps/web/src/components/file-browser/details/FolderDetails.tsxapps/web/src/components/file-browser/useFileBrowserActions.tsapps/web/src/hooks/__tests__/useSharedWriteOps.test.tsapps/web/src/hooks/__tests__/useSyncPolling.test.tsapps/web/src/hooks/useFolderMutations.tsapps/web/src/hooks/useFolderNavigation.tsapps/web/src/services/invite.service.tsapps/web/src/stores/__tests__/folder.store.test.tspackages/core/src/__tests__/bin.test.tspackages/sdk-core/src/__tests__/file.test.tspackages/sdk-core/src/folder/__tests__/load.test.tspackages/sdk/src/__tests__/folder-listing.test.tspackages/sdk/src/folder-listing.ts
…ed date Single-folder delete walked the store for descendants AFTER the SDK delete, while batch delete snapshots before; store events during deleteWithBehavior could prune descendants first, leaving stale entries. Snapshot the loaded subtree before the delete in the single path too (mirrors batch). The node decoder defaults a missing createdAt to 0 (Number(obj.createdAt ?? 0)), which the Details Created row rendered as 'January 1, 1970' since Number.isFinite(0) is true. Guard both detail panes on createdAt > 0 so a real epoch-0 node renders the dim placeholder.
…es by kind handleDeleteItems removed folders from the store only after the whole loop, so a mid-loop throw stranded already-deleted folders as stale entries. Purge each folder's subtree from the store immediately after its own successful delete. SharedMoveDialog's single-item title always read 'Move Folder' even though shared file moves are supported; it now reflects the moved item's real kind. Clarify the folder-listing doc comment: only file size is optional; createdAt, modifiedAt, and sequence are mandatory.
…deferred-test-revival # Conflicts: # .planning/STATE.md
Release Preview
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #611 +/- ##
=======================================
Coverage 70.12% 70.12%
=======================================
Files 159 159
Lines 15322 15323 +1
Branches 1950 1950
=======================================
+ Hits 10744 10745 +1
Misses 4324 4324
Partials 254 254
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Phase 79 — Web Kind-Discrimination Completion and Deferred Test Revival
Routes the file-browser UI through the SDK-resolved
ResolvedChild.kindprojection, wires a real Created date, and revives (or explicitly retires) the four deferreddescribe.skipsuites, drivingTODO(phase 63/65)markers to zero.What changed
ResolvedChild.kindviaisFileRefResolved(item, resolvedByIpnsName)instead of a hardcoded'folder'stub. Move cycle-guards (private + shared) now exclude only real folder-kind items.ResolvedChild.createdAtis now a mandatory field sourced from the already-unsealed Node envelope inresolveChildren; the Details panes render it (Number.isFinite-guarded) instead of the "unavailable (phase 63)" stub.collectDescendantFolderIdsBFS purges a deleted folder's descendantFolderNodeentries (walkparentIdlinks) so no stale entry survives theisLoadedfast path. Identity staysipnsName-keyed — noNode.idre-key.fetchAndDecryptMetadata(load.test.ts) revived against the node/v3 contract;useSharedWriteOpsmove + batch-move suites revived;bin.test.tsnodeReffixture populated andrestoreFromBinun-skipped;updateFileMetadataCAS suite retired with rationale (current fn is single-shot; equivalent coverage already infile/file-node.test.ts).InviteInfo.itemTypedropped — no data source at the invite layer, no consumer.Locked invariants respected
ipnsName, neverNode.id(a prior UUID re-key caused an orphaned-store bug, 68.1/68.2-09). The re-key TODO was deleted as a documented NON-CHANGE.integration.test.tsstaysdescribe.skip(3 skipped).Gates
TODO(phase 63/65)markers in product code (79-VERIFICATION.md).79-SECURITY.md).79-VALIDATION.md).TEE_WORKER_URLpoints at the mock on :3001, so the real worker on :3002 never renews — epoch-mismatch logged at boot), unrelated to this phase's domain. Accepted infra override.Ship-time review fixes
CodeRabbit CLI (pre-ship) + the PR-level CodeRabbit/Greptile reviews, triaged and addressed:
fix(79): DetailsDialog listing-miss fallback usedcreatedAt: 0, which theNumber.isFiniteguard rendered as "January 1, 1970" — switched toNumber.NaN.fix(79): the node decoder defaults a missingcreatedAtto0, so a real epoch-0 node also rendered "1970" — both Details Created rows now guard oncreatedAt > 0(Greptile P2).fix(79): single-folder delete walked the store for descendants after the SDK delete while batch delete snapshotted before — single-delete now snapshots before too (Greptile P1).fix(79): batch delete removed folders from the store only after the loop, stranding earlier successes on a mid-loop throw — now purges each folder's subtree inline after its own success.fix(79): SharedMoveDialog's single-item title always read "Move Folder" — now reflects the moved item's real kind.test(79): the folder-listing fixture hadcreatedAt === modifiedAt, hiding a field-projection swap — threaded a distinctcreatedAtand assert the inequality.ResolvedChildcomment (onlysizeis optional);.planning/STATE.mdstatus reconciled tocomplete;79-07-SUMMARY.mdcreatedAt-sentinel record updated.Triage tally
Across both review stages: 7 code/test fixes + 2 doc reconciliations applied; 0 todos logged. All 4 PR review threads resolved (2 Greptile, 2 CodeRabbit).
Discarded (nits/false-positives/pre-existing): the CodeRabbit CLI "treat unresolved kind as a third state / fail-closed" cluster (79-02/04/05/06 plans) — the folder-safe map-miss default is intentional and documented, the server validates every move/drop, and the suggestions were internally inconsistent (fail-open for drops vs fail-closed for cycle-guards); the SharedMoveDialog "id-space mismatch" (false positive —
enumerateSharedSubtreesetsid,parentId, andipnsNameall to the ipnsName, verified in code); thebin.test.tsfileKeyfinding (out-of-domain pre-existing bin wire format; fixture matches current behavior, bin-operations E2E passes); and the.planning/VALIDATION.md matrix-status nit.Merge status
Base
mainadvanced by one commit —#610(Phase 76,fix: harden FUSE publish and TEE write paths) — after this branch forked. The PR therefore shows CONFLICTING, and GitHub cannot build a merge ref, so CI (ci.yml / PR Title Lint / PR Release Preview) has not run yet. The conflict is confined to the two shared planning-tracking files.planning/ROADMAP.mdand.planning/STATE.md(bookkeeping only) — there is zero code conflict (Phase 76 touched FUSE/TEE; Phase 79 touches web/sdk). Resolving is a trivial rebase onto the newmain, owned by the ship orchestrator; CI will run once rebased.🤖 Generated with Claude Code