Skip to content

Commit df5a387

Browse files
committed
fix(console): the catalog's org install waits until its app is served before refreshing (objectui#12097)
On a cloud-managed environment, doOrgInstall pulsed emitMetadataRefresh the moment installPackage answered, inside the runtime's stale-while-rebuild window, so every mounted MetadataProvider re-read and persisted the pre-install app list. The cloud branch now runs waitForServedApp (objectui#12087) on the row's manifest_id and pulses only once the app is served; the strip shows the package page's deploying, served and timeout states under the package's name. The installLocal branch is unchanged. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5330afd commit df5a387

3 files changed

Lines changed: 489 additions & 8 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
An org install from the Marketplace catalog's "Your organization" strip, on a cloud-managed environment, now waits until the environment serves the installed package's app before it refreshes the metadata cache (objectui#12097). The package page has done this since objectui#12087.
6+
7+
A cloud install answers once the control plane has written the installation. The environment's runtime then rebuilds its kernel and keeps serving the kernel from before the install until the rebuild lands (about 60 s, measured on staging for objectui#12087). The catalog page sent its metadata-refresh signal the moment the install answered. Every mounted metadata cache re-read its types on that signal and kept what it read, which was the pre-install app list, so the strip said "Installed" and the app never appeared in that tab.
8+
9+
- **Wait.** The catalog page now runs the same wait as the package page (`waitForServedApp`), on the row's `manifest_id`: it reads `GET /meta/app` past the cache, at once and then every 5 s for up to 5 minutes, until an app's `_packageId` is that manifest id. Only then does it send the refresh signal.
10+
- **Say what is happening.** Above the strip, under the package's name, it shows the package page's three states in the package page's words: "Deploying the app…" while it waits; that the app is ready, with "Open {name}", once it is served; and, when the wait expires, that the install was recorded but the app has not appeared yet, with "Check again". It no longer says "Installed {name}" before the app is served, and on expiry it refreshes nothing.
11+
- An install-local runtime is unchanged: it registers the package before it answers, so the strip says "Installed {name}" and refreshes at once, as before.
12+
13+
No language-pack key is added: the three states reuse the package page's `marketplace.install` keys (`deploying`, `deployed`, `deployTimeout`, `openApp`, `checkAgain`). No export, prop or type on the `@object-ui/app-shell` entry changes.

‎packages/app-shell/src/console/marketplace/MarketplacePage.tsx‎

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import {
1919
Button,
2020
Skeleton,
2121
} from '@object-ui/components';
22-
import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings } from 'lucide-react';
22+
import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react';
2323
import { useWorkspaceAdminStatus } from '@object-ui/auth';
24-
import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
24+
import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
25+
import { useAdapter } from '@object-ui/react';
2526
import { PackageIcon } from './PackageIcon.js';
2627
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js';
2728
import { MarketplaceResolving } from './MarketplaceResolving.js';
@@ -40,6 +41,8 @@ import {
4041
} from './marketplaceApi.js';
4142
import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js';
4243
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
44+
import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js';
45+
import { waitForServedApp, type ServedApp } from './waitForServedApp.js';
4346

4447
/**
4548
* Format a published-at timestamp as a localized relative string.
@@ -107,11 +110,29 @@ function toLoadFailure(e: unknown): LoadFailure {
107110
};
108111
}
109112

113+
/**
114+
* objectui#12097 — an org install on a cloud-managed environment, from the
115+
* install answering until the runtime serves the package's app, or the wait
116+
* expires: the three states the package page draws for the same install
117+
* (objectui#12087), in the same words. The strip lists several packages and
118+
* this status sits above them all, so it names its package (`name`, the row's
119+
* display name). `served` carries the first app of the package a user can open,
120+
* or `null` when every one is inactive or hidden.
121+
*/
122+
type OrgDeployState =
123+
| { phase: 'waiting'; manifestId: string; name: string }
124+
| { phase: 'served'; manifestId: string; name: string; app: ServedApp | null }
125+
| { phase: 'timeout'; manifestId: string; name: string };
126+
110127
export function MarketplacePage() {
111128
const navigate = useNavigate();
112129
const { appName } = useParams();
113130
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
114131
const { t, language } = useObjectTranslation();
132+
const { appLabel } = useObjectLabel();
133+
// The adapter `MetadataProvider` reads through: the deploy wait asks the
134+
// same `GET /meta/app` the cache would, without the cache persisting it.
135+
const adapter = useAdapter();
115136
const formatRelative = useRelativeFormatter();
116137
const basePath = appName ? `/apps/${appName}` : '';
117138
// The runtime's own answer, read once per render. `false` means this
@@ -137,6 +158,7 @@ export function MarketplacePage() {
137158
const [orgItems, setOrgItems] = useState<OrgPackageSummary[]>([]);
138159
const [orgInstalling, setOrgInstalling] = useState<string | null>(null);
139160
const [orgMsg, setOrgMsg] = useState<{ ok: boolean; text: string } | null>(null);
161+
const [orgDeploy, setOrgDeploy] = useState<OrgDeployState | null>(null);
140162
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
141163

142164
const installedByManifestId = useMemo(() => {
@@ -190,6 +212,44 @@ export function MarketplacePage() {
190212
void load();
191213
}, [marketplaceEnabled]);
192214

215+
/**
216+
* objectui#12097 — wait until this environment serves the installed org
217+
* package's app, and only then pulse the metadata refresh.
218+
*
219+
* The pulse makes every mounted `MetadataProvider` re-read its loaded types
220+
* and persist what it reads (the tab's sessionStorage seed). A pulse that
221+
* lands while the runtime still serves the pre-install kernel fixes the
222+
* pre-install `app` list in place: the install "succeeded" and the app never
223+
* appeared. `waitForServedApp` (objectui#12087) reads `GET /meta/app` past
224+
* the cache instead, and the pulse waits for it. On expiry nothing is
225+
* refreshed, and the strip says the app has not appeared rather than that
226+
* the install succeeded.
227+
*
228+
* One status for the whole strip: a later install's status replaces an
229+
* earlier one's, and an earlier wait that settles late never overwrites it.
230+
* Its pulse still goes out, because its app IS served.
231+
*/
232+
const awaitServedOrgInstall = async (manifestId: string, name: string) => {
233+
const settle = (next: OrgDeployState) =>
234+
setOrgDeploy((cur) => (cur?.manifestId === manifestId ? next : cur));
235+
setOrgDeploy({ phase: 'waiting', manifestId, name });
236+
const wait = await waitForServedApp({
237+
manifestId,
238+
// The SDK's declared list answer (`GetMetaItemsResponse`: `{ type,
239+
// items }`), read the way the package page reads it.
240+
readApps: async () => {
241+
if (!adapter) throw new Error('No data adapter to read the app list through');
242+
return (await adapter.getClient().meta.getItems('app')).items;
243+
},
244+
});
245+
if (!wait.served) {
246+
settle({ phase: 'timeout', manifestId, name });
247+
return;
248+
}
249+
emitMetadataRefresh();
250+
settle({ phase: 'served', manifestId, name, app: filterActiveApps(wait.apps)[0] ?? null });
251+
};
252+
193253
// Install an org package into the current environment (ADR-0007 step ②).
194254
//
195255
// Two deployment shapes, two semantically-correct routes:
@@ -207,15 +267,23 @@ export function MarketplacePage() {
207267
try {
208268
if (getRuntimeConfig().features.installLocal) {
209269
await installLocal({ packageId: pkg.manifest_id || pkg.id, versionId: 'latest' });
270+
setOrgMsg({ ok: true, text: t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name }) });
271+
await load();
272+
// The runtime hot-registered the package before it answered, so the
273+
// live registry already holds it. Pulse every mounted MetadataProvider
274+
// so the new app appears in the nav (and its objects/views resolve)
275+
// without a manual browser refresh.
276+
emitMetadataRefresh();
210277
} else {
211278
await installPackage({ packageId: pkg.id, environmentId: '', seedSampleData: true });
279+
// objectui#12097 — this answer lands while the runtime still serves the
280+
// PRE-install kernel, so nothing is refreshed here: the wait refreshes
281+
// once the package's app is served. Not awaited, and not cancelled on
282+
// unmount, as on the package page: an operator who leaves this page
283+
// while the app deploys still gets the refreshed cache when it lands.
284+
void awaitServedOrgInstall(pkg.manifest_id, pkg.display_name);
285+
await load();
212286
}
213-
setOrgMsg({ ok: true, text: t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name }) });
214-
await load();
215-
// The install merged a package into the live registry. Pulse every
216-
// mounted MetadataProvider so the new app appears in the nav (and its
217-
// objects/views resolve) without a manual browser refresh.
218-
emitMetadataRefresh();
219287
} catch (e: any) {
220288
setOrgMsg({ ok: false, text: e?.message ?? String(e) });
221289
} finally {
@@ -280,6 +348,60 @@ export function MarketplacePage() {
280348

281349
if (!isAdmin) return <MarketplaceAccessDenied />;
282350

351+
// objectui#12097 — the org install's deploy status, drawn as the package
352+
// page draws it (objectui#12087), under the package's name.
353+
const orgDeployApp = orgDeploy?.phase === 'served' ? orgDeploy.app : null;
354+
const orgDeployNote = orgDeploy && (
355+
<div
356+
role="status"
357+
data-testid="org-deploy-status"
358+
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
359+
orgDeploy.phase === 'served'
360+
? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400'
361+
: orgDeploy.phase === 'timeout'
362+
? 'border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400'
363+
: 'border-border bg-muted/40 text-foreground'
364+
}`}
365+
>
366+
{orgDeploy.phase === 'waiting'
367+
? <Loader2 className="h-4 w-4 mt-0.5 shrink-0 animate-spin" aria-hidden="true" />
368+
: orgDeploy.phase === 'served'
369+
? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />
370+
: <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
371+
<div className="flex-1 space-y-2">
372+
<div>
373+
<div className="font-medium">{orgDeploy.name}</div>
374+
<div>
375+
{orgDeploy.phase === 'waiting'
376+
? t('marketplace.install.deploying')
377+
: orgDeploy.phase === 'served'
378+
? t('marketplace.install.deployed')
379+
: t('marketplace.install.deployTimeout')}
380+
</div>
381+
</div>
382+
{orgDeployApp && appRouteSegment(orgDeployApp) ? (
383+
<Button size="sm" onClick={() => navigate(`/apps/${appRouteSegment(orgDeployApp)}`)}>
384+
{t('marketplace.install.openApp', {
385+
name: appLabel({
386+
name: String(orgDeployApp.name ?? appRouteSegment(orgDeployApp)),
387+
label: resolveKeyedI18nLabel(orgDeployApp.label, t),
388+
}),
389+
})}
390+
</Button>
391+
) : null}
392+
{orgDeploy.phase === 'timeout' ? (
393+
<Button
394+
size="sm"
395+
variant="outline"
396+
onClick={() => void awaitServedOrgInstall(orgDeploy.manifestId, orgDeploy.name)}
397+
>
398+
{t('marketplace.install.checkAgain')}
399+
</Button>
400+
) : null}
401+
</div>
402+
</div>
403+
);
404+
283405
return (
284406
<div className="flex flex-col gap-6 p-4 sm:p-6">
285407
<div className="flex items-start justify-between gap-4 flex-wrap">
@@ -383,6 +505,7 @@ export function MarketplacePage() {
383505
</h2>
384506
<Badge variant="secondary" className="text-xs">{orgItems.length}</Badge>
385507
</div>
508+
{orgDeployNote}
386509
{orgMsg && (
387510
<div className={`rounded-md border p-3 text-sm ${orgMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
388511
{orgMsg.text}

0 commit comments

Comments
 (0)