Skip to content

Commit b005482

Browse files
committed
fix(tui): render /plugins marketplace before version lookups resolve
1 parent c8efdac commit b005482

5 files changed

Lines changed: 215 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Show the /plugins marketplace catalog as soon as it loads, with latest-version lookups running in the background.

apps/pythinker-code/src/constant/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,7 @@ export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED;
7575
export {
7676
PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV,
7777
} from '@pymodel/agent-core-v2/app/plugin/marketplace';
78+
// Bound on each background "latest release" lookup when the TUI fills in
79+
// marketplace versions. Without it a stalled connection to github.com hangs
80+
// the version phase for undici's default header timeout (300s).
81+
export const MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS = 5000;

apps/pythinker-code/src/tui/commands/plugins.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ import {
3535
isOfficialPluginSource,
3636
} from '../utils/plugin-source-label';
3737
import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app';
38-
import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace';
38+
import {
39+
loadPluginMarketplace,
40+
withBuiltInEntries,
41+
withMarketplaceLatestVersions,
42+
type PluginMarketplace,
43+
type PluginMarketplaceEntry,
44+
} from '#/utils/plugin-marketplace';
3945
import type { SlashCommandHost } from './dispatch';
4046

4147
interface ShowPluginsPickerOptions {
@@ -343,18 +349,49 @@ async function loadMarketplaceCatalog(
343349
source: string | undefined,
344350
capabilities: readonly CapabilityStatus[],
345351
): Promise<void> {
352+
const builtInEntries =
353+
host.engineV2 && isDefaultMarketplaceCatalog(source)
354+
? capabilities.map(capabilityMarketplaceEntry)
355+
: undefined;
356+
let marketplace: PluginMarketplace;
357+
let catalog: PluginMarketplace;
346358
try {
347-
const marketplace = await loadPluginMarketplace({
359+
// Phase 1: render the catalog as soon as it arrives. Version lookups
360+
// (GitHub releases/latest round trips) must not gate the first paint.
361+
// Keep the raw parsed catalog for phase 2: injecting built-ins first
362+
// would mask the matching catalog entries' GitHub sources behind
363+
// `capability:<id>` rows, making their versions unresolvable.
364+
catalog = await loadPluginMarketplace({
348365
workDir: host.state.appState.workDir,
349366
source,
350-
builtInEntries:
351-
host.engineV2 && isDefaultMarketplaceCatalog(source)
352-
? capabilities.map(capabilityMarketplaceEntry)
353-
: undefined,
367+
skipLatestVersions: true,
354368
});
369+
marketplace =
370+
builtInEntries !== undefined ? withBuiltInEntries(catalog, builtInEntries) : catalog;
355371
panel.setMarketplace(marketplace.plugins, marketplace.source);
372+
host.state.ui.requestRender();
356373
} catch (error) {
374+
// Any phase-1 failure (unreachable OR malformed catalog) surfaces as an
375+
// error: the panel keeps built-in capability rows installable in the
376+
// Official tab while the error is shown, and a broken catalog must not
377+
// be masked as a successfully loaded, built-ins-only marketplace.
357378
panel.setMarketplaceError(formatErrorMessage(error));
379+
host.state.ui.requestRender();
380+
return;
381+
}
382+
try {
383+
// Phase 2: resolve latest versions in the background (against the raw
384+
// catalog), re-apply the built-in injection so resolved versions flow
385+
// onto capability rows, then refresh so update badges appear. Failures
386+
// degrade to badge-less rows and never clobber the rendered list.
387+
const enrichedCatalog = await withMarketplaceLatestVersions(catalog);
388+
const enriched =
389+
builtInEntries !== undefined
390+
? withBuiltInEntries(enrichedCatalog, builtInEntries)
391+
: enrichedCatalog;
392+
panel.setMarketplace(enriched.plugins, enriched.source);
393+
} catch (error) {
394+
log.warn('marketplace version lookup failed', { error });
358395
}
359396
host.state.ui.requestRender();
360397
}

apps/pythinker-code/src/utils/plugin-marketplace.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ import {
1616
type PluginMarketplaceEntry,
1717
} from '@pymodel/agent-core-v2/app/plugin/marketplace';
1818

19-
import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app';
19+
import {
20+
PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV,
21+
MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS,
22+
} from '#/constant/app';
2023

2124
export {
2225
computeUpdateStatus,
2326
PLUGIN_MARKETPLACE_TIERS,
27+
withBuiltInEntries,
2428
type PluginMarketplace,
2529
type PluginMarketplaceEntry,
2630
type PluginMarketplaceTier,
@@ -37,6 +41,31 @@ export interface LoadPluginMarketplaceOptions {
3741
* Undefined means no injection.
3842
*/
3943
readonly builtInEntries?: readonly PluginMarketplaceEntry[];
44+
/**
45+
* Skip the per-entry "latest GitHub release" lookups so the catalog can be
46+
* rendered as soon as it is parsed; the caller resolves versions in the
47+
* background via {@link withMarketplaceLatestVersions} and re-renders.
48+
*/
49+
readonly skipLatestVersions?: boolean;
50+
}
51+
52+
/**
53+
* Second phase of the marketplace load: fill in `version` for entries that
54+
* need a GitHub `releases/latest` lookup. Every lookup gets a hard timeout
55+
* (MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS) and per-entry failures degrade to
56+
* a missing version (badge-less row), so this never throws for network
57+
* reasons and never blocks the first paint.
58+
*/
59+
export async function withMarketplaceLatestVersions(
60+
marketplace: PluginMarketplace,
61+
fetchImpl: typeof fetch = fetch,
62+
): Promise<PluginMarketplace> {
63+
const timedFetch: typeof fetch = (input, init) =>
64+
fetchImpl(input, {
65+
...init,
66+
signal: AbortSignal.timeout(MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS),
67+
});
68+
return withLatestVersions(marketplace, timedFetch);
4069
}
4170

4271
export async function loadPluginMarketplace(
@@ -63,9 +92,8 @@ export async function loadPluginMarketplace(
6392
}
6493
throw error;
6594
}
66-
const marketplace = await withLatestVersions(
67-
parsePluginMarketplace(read.raw, read.location),
68-
fetchImpl,
69-
);
95+
const marketplace = options.skipLatestVersions === true
96+
? parsePluginMarketplace(read.raw, read.location)
97+
: await withLatestVersions(parsePluginMarketplace(read.raw, read.location), fetchImpl);
7098
return withBuiltInEntries(marketplace, builtInEntries);
7199
}

apps/pythinker-code/test/utils/plugin-marketplace.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { fileURLToPath } from 'node:url';
66
import { describe, expect, it, vi } from 'vitest';
77

88
import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app';
9-
import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace';
9+
import {
10+
computeUpdateStatus,
11+
loadPluginMarketplace,
12+
withBuiltInEntries,
13+
withMarketplaceLatestVersions,
14+
type PluginMarketplaceEntry,
15+
} from '#/utils/plugin-marketplace';
1016

1117
const REPO_ROOT = join(import.meta.dirname, '../../../..');
1218

@@ -575,4 +581,127 @@ describe('loadPluginMarketplace', () => {
575581
);
576582
});
577583

584+
describe('two-phase version lookup', () => {
585+
async function writeCatalog(dir: string) {
586+
const file = join(dir, 'marketplace.json');
587+
await writeFile(
588+
file,
589+
JSON.stringify({
590+
plugins: [
591+
{ id: 'demo', displayName: 'Demo', source: 'https://github.com/owner/repo' },
592+
],
593+
}),
594+
'utf8',
595+
);
596+
return file;
597+
}
598+
599+
it('skipLatestVersions returns the catalog without querying GitHub', async () => {
600+
const fetchImpl = vi.fn(async () => {
601+
throw new Error('should not be called');
602+
}) as unknown as typeof fetch;
603+
const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-'));
604+
const file = await writeCatalog(dir);
605+
606+
const marketplace = await loadPluginMarketplace({
607+
workDir: dir,
608+
source: file,
609+
fetchImpl,
610+
skipLatestVersions: true,
611+
});
612+
613+
expect(marketplace.plugins[0]?.version).toBeUndefined();
614+
expect(fetchImpl).not.toHaveBeenCalled();
615+
});
616+
617+
it('withMarketplaceLatestVersions fills versions from the latest release redirect', async () => {
618+
const fetchImpl = vi.fn(async (input: unknown) => ({
619+
ok: false,
620+
status: 302,
621+
headers: new Headers({
622+
location: 'https://github.com/owner/repo/releases/tag/v1.2.3',
623+
}),
624+
text: async () => '',
625+
})) as unknown as typeof fetch;
626+
const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-'));
627+
const file = await writeCatalog(dir);
628+
const marketplace = await loadPluginMarketplace({
629+
workDir: dir,
630+
source: file,
631+
skipLatestVersions: true,
632+
});
633+
634+
const enriched = await withMarketplaceLatestVersions(marketplace, fetchImpl);
635+
636+
expect(fetchImpl).toHaveBeenCalledWith(
637+
'https://github.com/owner/repo/releases/latest',
638+
expect.objectContaining({ redirect: 'manual', signal: expect.any(AbortSignal) }),
639+
);
640+
expect(enriched.plugins[0]?.version).toBe('1.2.3');
641+
});
642+
643+
it('withMarketplaceLatestVersions degrades to a missing version when the lookup aborts', async () => {
644+
const fetchImpl = vi.fn(async (_input: unknown, init?: { signal?: AbortSignal }) => {
645+
// Simulate the lookup hitting the timeout: undici rejects with the
646+
// signal's reason once the AbortSignal fires.
647+
throw init?.signal?.aborted === true
648+
? init.signal.reason
649+
: new DOMException('This operation was aborted', 'AbortError');
650+
}) as unknown as typeof fetch;
651+
const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-'));
652+
const file = await writeCatalog(dir);
653+
const marketplace = await loadPluginMarketplace({
654+
workDir: dir,
655+
source: file,
656+
skipLatestVersions: true,
657+
});
658+
659+
const enriched = await withMarketplaceLatestVersions(marketplace, fetchImpl);
660+
661+
expect(enriched.plugins[0]?.version).toBeUndefined();
662+
expect(enriched.plugins[0]?.id).toBe('demo');
663+
});
664+
665+
it('carries a resolved catalog version onto a built-in row injected after enrichment', async () => {
666+
// Regression for the resolve-before-inject ordering: enriching the
667+
// built-in-masked marketplace cannot see the catalog entry's GitHub
668+
// source, so built-in rows would never get update badges.
669+
const fetchImpl = vi.fn(async () => ({
670+
ok: false,
671+
status: 302,
672+
headers: new Headers({
673+
location: 'https://github.com/owner/repo/releases/tag/v2.0.0',
674+
}),
675+
text: async () => '',
676+
})) as unknown as typeof fetch;
677+
const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-'));
678+
const file = join(dir, 'marketplace.json');
679+
await writeFile(
680+
file,
681+
JSON.stringify({
682+
plugins: [{ id: 'demo', displayName: 'Demo', source: 'https://github.com/owner/repo' }],
683+
}),
684+
'utf8',
685+
);
686+
const catalog = await loadPluginMarketplace({
687+
workDir: dir,
688+
source: file,
689+
skipLatestVersions: true,
690+
});
691+
const builtIns: readonly PluginMarketplaceEntry[] = [
692+
{ id: 'demo', displayName: 'Demo Capability', source: 'capability:demo', builtIn: true },
693+
];
694+
695+
const enriched = withBuiltInEntries(
696+
await withMarketplaceLatestVersions(catalog, fetchImpl),
697+
builtIns,
698+
);
699+
700+
expect(enriched.plugins).toHaveLength(1);
701+
expect(enriched.plugins[0]).toEqual(
702+
expect.objectContaining({ id: 'demo', builtIn: true, version: '2.0.0' }),
703+
);
704+
});
705+
});
706+
578707
});

0 commit comments

Comments
 (0)