Skip to content

Commit 307d108

Browse files
authored
Merge pull request #75 from tyulyukov/marcode/pr-provider-update-advisories
feat: provider update advisories with one-click updates
2 parents 14dcb5d + 2da4ab5 commit 307d108

37 files changed

Lines changed: 2886 additions & 60 deletions

apps/server/src/provider/Layers/ClaudeProvider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import {
3939
} from "../providerSnapshot.ts";
4040
import { compareCliVersions } from "../cliVersion.ts";
4141
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
42+
import {
43+
enrichProviderSnapshotWithVersionAdvisory,
44+
getProviderVersionLifecycle,
45+
} from "../providerVersionLifecycle.ts";
4246
import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
4347
import { ServerSettingsService } from "../../serverSettings.ts";
4448
import { ServerSettingsError } from "@marcode/contracts";
@@ -914,6 +918,7 @@ export const ClaudeProviderLive = Layer.effect(
914918
);
915919

916920
return yield* makeManagedServerProvider<ClaudeSettings>({
921+
versionLifecycle: getProviderVersionLifecycle(PROVIDER),
917922
getSettings: serverSettings.getSettings.pipe(
918923
Effect.map((settings) => settings.providers.claudeAgent),
919924
Effect.orDie,
@@ -924,6 +929,14 @@ export const ClaudeProviderLive = Layer.effect(
924929
haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
925930
initialSnapshot: makePendingClaudeProvider,
926931
checkProvider,
932+
enrichSnapshot: ({ snapshot, publishSnapshot }) =>
933+
Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe(
934+
Effect.flatMap((enrichedSnapshot) =>
935+
Equal.equals(enrichedSnapshot, snapshot)
936+
? Effect.void
937+
: publishSnapshot(enrichedSnapshot),
938+
),
939+
),
927940
});
928941
}),
929942
);

apps/server/src/provider/Layers/CodexProvider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import { ServerSettingsError } from "@marcode/contracts";
2828
import { createModelCapabilities } from "@marcode/shared/model";
2929

3030
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
31+
import {
32+
enrichProviderSnapshotWithVersionAdvisory,
33+
getProviderVersionLifecycle,
34+
} from "../providerVersionLifecycle.ts";
3135
import { buildServerProvider } from "../providerSnapshot.ts";
3236
import { CodexProvider } from "../Services/CodexProvider.ts";
3337
import { expandHomePath } from "../../pathExpansion.ts";
@@ -503,6 +507,7 @@ export const CodexProviderLive = Layer.effect(
503507
);
504508

505509
return yield* makeManagedServerProvider<CodexSettings>({
510+
versionLifecycle: getProviderVersionLifecycle(PROVIDER),
506511
getSettings: serverSettings.getSettings.pipe(
507512
Effect.map((settings) => settings.providers.codex),
508513
Effect.orDie,
@@ -513,6 +518,14 @@ export const CodexProviderLive = Layer.effect(
513518
haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
514519
initialSnapshot: makePendingCodexProvider,
515520
checkProvider,
521+
enrichSnapshot: ({ snapshot, publishSnapshot }) =>
522+
Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe(
523+
Effect.flatMap((enrichedSnapshot) =>
524+
Equal.equals(enrichedSnapshot, snapshot)
525+
? Effect.void
526+
: publishSnapshot(enrichedSnapshot),
527+
),
528+
),
516529
refreshInterval: Duration.minutes(5),
517530
});
518531
}),

apps/server/src/provider/Layers/CursorProvider.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import {
3131
type CommandResult,
3232
} from "../providerSnapshot.ts";
3333
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
34+
import {
35+
enrichProviderSnapshotWithVersionAdvisory,
36+
getProviderVersionLifecycle,
37+
} from "../providerVersionLifecycle.ts";
3438
import { CursorProvider } from "../Services/CursorProvider.ts";
3539
import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
3640
import { ServerSettingsService } from "../../serverSettings.ts";
@@ -1169,6 +1173,7 @@ export const CursorProviderLive = Layer.effect(
11691173
);
11701174

11711175
return yield* makeManagedServerProvider<CursorSettings>({
1176+
versionLifecycle: getProviderVersionLifecycle(PROVIDER),
11721177
getSettings: serverSettings.getSettings.pipe(
11731178
Effect.map((settings) => settings.providers.cursor),
11741179
Effect.orDie,
@@ -1180,37 +1185,58 @@ export const CursorProviderLive = Layer.effect(
11801185
initialSnapshot: buildInitialCursorProviderSnapshot,
11811186
checkProvider,
11821187
enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => {
1183-
if (
1184-
!settings.enabled ||
1185-
snapshot.auth.status === "unauthenticated" ||
1186-
!snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model))
1187-
) {
1188-
return Effect.void;
1189-
}
1188+
const enrichVersionAdvisory = Effect.promise(() =>
1189+
enrichProviderSnapshotWithVersionAdvisory(snapshot),
1190+
).pipe(
1191+
Effect.flatMap((enrichedSnapshot) =>
1192+
Equal.equals(enrichedSnapshot, snapshot)
1193+
? Effect.succeed(enrichedSnapshot)
1194+
: publishSnapshot(enrichedSnapshot).pipe(Effect.as(enrichedSnapshot)),
1195+
),
1196+
Effect.catchCause((cause) =>
1197+
Effect.logWarning("Cursor version advisory enrichment failed", {
1198+
cause: Cause.pretty(cause),
1199+
}).pipe(Effect.as(snapshot)),
1200+
),
1201+
);
11901202

1191-
return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe(
1192-
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1193-
Effect.flatMap((discoveredModels) => {
1194-
if (discoveredModels.length === 0) {
1203+
return enrichVersionAdvisory.pipe(
1204+
Effect.flatMap((baseSnapshot) => {
1205+
if (
1206+
!settings.enabled ||
1207+
baseSnapshot.auth.status === "unauthenticated" ||
1208+
!baseSnapshot.models.some(
1209+
(model) => !model.isCustom && !hasCursorModelCapabilities(model),
1210+
)
1211+
) {
11951212
return Effect.void;
11961213
}
11971214

1198-
return publishSnapshot({
1199-
...snapshot,
1200-
models: providerModelsFromSettings(
1201-
discoveredModels,
1202-
PROVIDER,
1203-
settings.customModels,
1204-
EMPTY_CAPABILITIES,
1215+
return discoverCursorModelCapabilitiesViaAcp(settings, baseSnapshot.models).pipe(
1216+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1217+
Effect.flatMap((discoveredModels) => {
1218+
if (discoveredModels.length === 0) {
1219+
return Effect.void;
1220+
}
1221+
1222+
return publishSnapshot({
1223+
...baseSnapshot,
1224+
models: providerModelsFromSettings(
1225+
discoveredModels,
1226+
PROVIDER,
1227+
settings.customModels,
1228+
EMPTY_CAPABILITIES,
1229+
),
1230+
});
1231+
}),
1232+
Effect.catchCause((cause) =>
1233+
Effect.logWarning("Cursor ACP background capability enrichment failed", {
1234+
models: baseSnapshot.models.map((model) => model.slug),
1235+
cause: Cause.pretty(cause),
1236+
}).pipe(Effect.asVoid),
12051237
),
1206-
});
1238+
);
12071239
}),
1208-
Effect.catchCause((cause) =>
1209-
Effect.logWarning("Cursor ACP background capability enrichment failed", {
1210-
models: snapshot.models.map((model) => model.slug),
1211-
cause: Cause.pretty(cause),
1212-
}).pipe(Effect.asVoid),
1213-
),
12141240
);
12151241
},
12161242
refreshInterval: CURSOR_REFRESH_INTERVAL,

apps/server/src/provider/Layers/OpenCodeProvider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { createModelCapabilities } from "@marcode/shared/model";
1111
import { ServerConfig } from "../../config.ts";
1212
import { ServerSettingsService } from "../../serverSettings.ts";
1313
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
14+
import {
15+
enrichProviderSnapshotWithVersionAdvisory,
16+
getProviderVersionLifecycle,
17+
} from "../providerVersionLifecycle.ts";
1418
import {
1519
buildServerProvider,
1620
nonEmptyTrimmed,
@@ -486,6 +490,7 @@ export const OpenCodeProviderLive = Layer.effect(
486490
);
487491

488492
return yield* makeManagedServerProvider<OpenCodeSettings>({
493+
versionLifecycle: getProviderVersionLifecycle(PROVIDER),
489494
getSettings: getProviderSettings.pipe(Effect.orDie),
490495
streamSettings: serverSettings.streamChanges.pipe(
491496
Stream.map((settings) => settings.providers.opencode),
@@ -500,6 +505,14 @@ export const OpenCodeProviderLive = Layer.effect(
500505
}),
501506
),
502507
),
508+
enrichSnapshot: ({ snapshot, publishSnapshot }) =>
509+
Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe(
510+
Effect.flatMap((enrichedSnapshot) =>
511+
Equal.equals(enrichedSnapshot, snapshot)
512+
? Effect.void
513+
: publishSnapshot(enrichedSnapshot),
514+
),
515+
),
503516
});
504517
}),
505518
);

apps/server/src/provider/Layers/ProviderRegistry.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./Cl
1818
import {
1919
haveProvidersChanged,
2020
mergeProviderSnapshot,
21+
mergeProviderSnapshots,
2122
ProviderRegistryLive,
23+
selectProvidersByKind,
2224
} from "./ProviderRegistry.ts";
2325
import { ServerConfig } from "../../config.ts";
2426
import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
@@ -427,6 +429,68 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
427429
]);
428430
});
429431

432+
it("persists merged provider snapshots for the providers that were refreshed", () => {
433+
const previousProviders = [
434+
{
435+
provider: "cursor",
436+
status: "ready",
437+
enabled: true,
438+
installed: true,
439+
auth: { status: "authenticated" },
440+
checkedAt: "2026-04-14T00:00:00.000Z",
441+
version: "2026.04.09-f2b0fcd",
442+
models: [
443+
{
444+
slug: "claude-opus-4-6",
445+
name: "Opus 4.6",
446+
isCustom: false,
447+
capabilities: createModelCapabilities({
448+
optionDescriptors: [
449+
selectDescriptor("reasoning", "Reasoning", [
450+
{ id: "high", label: "High", isDefault: true },
451+
]),
452+
booleanDescriptor("fastMode", "Fast Mode"),
453+
booleanDescriptor("thinking", "Thinking"),
454+
],
455+
}),
456+
},
457+
],
458+
slashCommands: [],
459+
skills: [],
460+
},
461+
{
462+
provider: "codex",
463+
status: "ready",
464+
enabled: true,
465+
installed: true,
466+
auth: { status: "authenticated" },
467+
checkedAt: "2026-04-14T00:00:00.000Z",
468+
version: "1.0.0",
469+
models: [],
470+
slashCommands: [],
471+
skills: [],
472+
},
473+
] as const satisfies ReadonlyArray<ServerProvider>;
474+
const refreshedCursor = {
475+
...previousProviders[0],
476+
checkedAt: "2026-04-14T00:01:00.000Z",
477+
models: [],
478+
} satisfies ServerProvider;
479+
480+
const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]);
481+
const persistedProviders = selectProvidersByKind(
482+
mergedProviders,
483+
new Set<ServerProvider["provider"]>(["cursor"]),
484+
);
485+
486+
assert.deepStrictEqual(persistedProviders, [
487+
{
488+
...refreshedCursor,
489+
models: [...previousProviders[0].models],
490+
},
491+
]);
492+
});
493+
430494
it.effect("probes enabled providers in the background during registry startup", () =>
431495
Effect.gen(function* () {
432496
let spawnCount = 0;

0 commit comments

Comments
 (0)