Marketplace: pin the commit the user reviewed (final review I2)

Install and update pinned whatever the marketplace head was when the
click landed, so a background refresh between review and click could
pin content nobody saw (including a hook's shell commands).
install_marketplace_item and update_marketplace_item now take
expected_commit and refuse with "changed since you reviewed this item —
review it again" unless it is still the head. The UI passes the head the
selected item was read at (Browse), the head frozen with a pending hook
confirm (whose commands are frozen too), and the head of the accepted
diff (Installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:11:19 -07:00
co-authored by Claude Opus 5.5
parent f2ebddd073
commit dd019cf2c0
13 changed files with 197 additions and 65 deletions
@@ -18,13 +18,25 @@ interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
/**
* The marketplace head `item` was read at. Installs pin exactly this commit;
* the backend refuses if the marketplace has moved on since (final review I2).
*/
headCommit: string | null;
}
export default function InstallControls({ mp, item, marketplaceId }: Props) {
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingHook {
scope: InstallScope;
item: CatalogItem;
commit: string;
}
export default function InstallControls({ mp, item, marketplaceId, headCommit }: Props) {
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pendingHook, setPendingHook] = useState<InstallScope | null>(null);
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
@@ -33,6 +45,8 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
);
const disabled = item.invalid !== null || busy;
// "" never matches a head, so the backend explains that a refresh is needed.
const commit = headCommit ?? "";
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
const run = async (fn: () => Promise<boolean>) => {
@@ -47,10 +61,10 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
/** Every install goes through here so a hook is always confirmed first. */
const install = (scope: InstallScope) => {
if (item.kind === "hook") {
setPendingHook(scope);
setPendingHook({ scope, item, commit });
return;
}
void run(() => mp.install(ref, scope));
void run(() => mp.install(ref, scope, commit));
};
const toggleProject = (projectId: string, state: ProjectItemState) => {
@@ -111,12 +125,13 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
)}
{pendingHook && (
<HookConfirmModal
item={item}
item={pendingHook.item}
commit={pendingHook.commit}
onCancel={() => setPendingHook(null)}
onConfirm={() => {
const scope = pendingHook;
const { scope, commit: reviewed } = pendingHook;
setPendingHook(null);
void run(() => mp.install(ref, scope));
void run(() => mp.install(ref, scope, reviewed));
}}
/>
)}