authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-05 01:33:25-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 01:33:25-04:00
logf47a3fe90354a3dfa14276e3699541d552426276
treedc60aadc92e40bb9b58f5fd723245f59ad3e08f5
parentc7dc3cbd068a93c9fb5027df7bea719d16e4ffbe
signature Signed by PGP key B5690EEEBB952194

fix(settings): share restart continuation across environments (#9933)


8 files changed, 204 insertions(+), 42 deletions(-)

apps/mobile/src/features/settings/SettingsRouteScreen.tsx+16-2
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44 type ServerSettingsPatch,44 type ServerSettingsPatch,
45} from "@t3tools/contracts";45} from "@t3tools/contracts";
46import {46import {
47 filterSharedServerPatch,
47 findSharedSettingsMismatches,48 findSharedSettingsMismatches,
48 pickSharedServerSettings,49 pickSharedServerSettings,
49 supportsSharedSettingsSync,50 supportsSharedSettingsSync,
...@@ -584,11 +585,13 @@ function AutoSettleSettingsRows() {...@@ -584,11 +585,13 @@ function AutoSettleSettingsRows() {
584 const mismatches = findSharedSettingsMismatches({585 const mismatches = findSharedSettingsMismatches({
585 primaryEnvironmentId: reference.environmentId,586 primaryEnvironmentId: reference.environmentId,
586 primarySettings: referenceSettings,587 primarySettings: referenceSettings,
588 primaryCapabilities: reference.serverConfig?.environment.capabilities,
587 environments: environments.map((environment) => ({589 environments: environments.map((environment) => ({
588 environmentId: environment.environmentId,590 environmentId: environment.environmentId,
589 label: environment.label,591 label: environment.label,
590 syncEligible: supportsSharedSettingsSync(environment),592 syncEligible: supportsSharedSettingsSync(environment),
591 settings: environment.serverConfig?.settings ?? null,593 settings: environment.serverConfig?.settings ?? null,
594 capabilities: environment.serverConfig?.environment.capabilities,
592 })),595 })),
593 });596 });
594597
...@@ -652,11 +655,22 @@ function AutoSettleSettingsRows() {...@@ -652,11 +655,22 @@ function AutoSettleSettingsRows() {
652 <Pressable655 <Pressable
653 accessibilityRole="button"656 accessibilityRole="button"
654 onPress={() => {657 onPress={() => {
655 const patch = pickSharedServerSettings(referenceSettings);658 const patch = pickSharedServerSettings(
659 referenceSettings,
660 reference.serverConfig?.environment.capabilities,
661 );
656 for (const mismatch of mismatches) {662 for (const mismatch of mismatches) {
663 const target = environments.find(
664 (candidate) => candidate.environmentId === mismatch.environmentId,
665 );
657 void updateSettings({666 void updateSettings({
658 environmentId: mismatch.environmentId,667 environmentId: mismatch.environmentId,
659 input: { patch },668 input: {
669 patch: filterSharedServerPatch(
670 patch,
671 target?.serverConfig?.environment.capabilities,
672 ),
673 },
660 });674 });
661 }675 }
662 }}676 }}
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -219,6 +219,7 @@ export const make = Effect.gen(function* () {...@@ -219,6 +219,7 @@ export const make = Effect.gen(function* () {
219 pullRequests: true,219 pullRequests: true,
220 threadSettlement: true,220 threadSettlement: true,
221 threadAutoSettlement: true,221 threadAutoSettlement: true,
222 threadRestartContinuation: true,
222 threadSnooze: true,223 threadSnooze: true,
223 environmentThemes: true,224 environmentThemes: true,
224 usageLimitSources: true,225 usageLimitSources: true,
apps/web/src/components/settings/SettingsPanels.tsx+1-1
...@@ -2439,7 +2439,7 @@ export function GeneralSettingsPanel() {...@@ -2439,7 +2439,7 @@ export function GeneralSettingsPanel() {
2439 <SettingsRow2439 <SettingsRow
2440 {...searchableSetting("continue-threads-after-server-update")}2440 {...searchableSetting("continue-threads-after-server-update")}
2441 serverScoped2441 serverScoped
2442 description="Automatically resume interrupted threads when this environment starts again after an update, crash, or machine restart."2442 description="Automatically resume interrupted threads after an update, crash, or machine restart. Applies to this environment and all connected environments that support it. Update older servers first."
2443 resetAction={2443 resetAction={
2444 settings.continueThreadsAfterServerUpdate !==2444 settings.continueThreadsAfterServerUpdate !==
2445 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (2445 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
apps/web/src/hooks/useSettings.ts+34-25
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26} from "@t3tools/contracts/settings";26} from "@t3tools/contracts/settings";
27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import {28import {
29 filterSharedServerPatch,
29 findSharedSettingsMismatches,30 findSharedSettingsMismatches,
30 pickSharedServerSettings,31 pickSharedServerSettings,
31 splitSharedServerPatch,32 splitSharedServerPatch,
...@@ -368,18 +369,6 @@ export function usePrimarySettingsAvailable(): boolean {...@@ -368,18 +369,6 @@ export function usePrimarySettingsAvailable(): boolean {
368 return primaryEnvironment !== null || !isHostedStaticApp();369 return primaryEnvironment !== null || !isHostedStaticApp();
369}370}
370371
371/** Environments that can receive a shared settings write right now. */
372function useSharedSettingsSyncTargetIds(): ReadonlyArray<EnvironmentId> {
373 const { environments } = useEnvironments();
374 return useMemo(
375 () =>
376 environments
377 .filter(supportsSharedSettingsSync)
378 .map((environment) => environment.environmentId),
379 [environments],
380 );
381}
382
383/**372/**
384 * Returns an updater that routes each key to the correct backing store.373 * Returns an updater that routes each key to the correct backing store.
385 *374 *
...@@ -394,7 +383,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -394,7 +383,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
394 serverEnvironment.updateSettings,383 serverEnvironment.updateSettings,
395 "server settings update",384 "server settings update",
396 );385 );
397 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds();386 const { environments } = useEnvironments();
398 const updateSettings = useCallback(387 const updateSettings = useCallback(
399 (patch: UnifiedSettingsPatch) => {388 (patch: UnifiedSettingsPatch) => {
400 const { serverPatch, clientPatch } = splitPatch(patch);389 const { serverPatch, clientPatch } = splitPatch(patch);
...@@ -402,11 +391,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -402,11 +391,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
402 if (Object.keys(serverPatch).length > 0) {391 if (Object.keys(serverPatch).length > 0) {
403 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);392 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
404 // Dropping the write silently leaves the control looking saved.393 // Dropping the write silently leaves the control looking saved.
405 const warnUnsaved = () =>394 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>
406 toastManager.add({395 toastManager.add({
407 type: "warning",396 type: "warning",
408 title: "Setting not saved",397 title: "Setting not saved",
409 description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,398 description,
410 });399 });
411 if (Object.keys(localPatch).length > 0) {400 if (Object.keys(localPatch).length > 0) {
412 if (environmentId) {401 if (environmentId) {
...@@ -419,26 +408,38 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -419,26 +408,38 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
419 }408 }
420 }409 }
421 if (Object.keys(sharedPatch).length > 0) {410 if (Object.keys(sharedPatch).length > 0) {
422 const targets = new Set(sharedSettingsSyncTargetIds);411 const targets = new Set(
412 environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId),
413 );
423 if (environmentId) {414 if (environmentId) {
424 targets.add(environmentId);415 targets.add(environmentId);
425 }416 }
426 if (targets.size === 0) {417 let wroteToTarget = false;
427 warnUnsaved();
428 }
429 for (const targetId of targets) {418 for (const targetId of targets) {
419 const target = environments.find((candidate) => candidate.environmentId === targetId);
420 const targetPatch = filterSharedServerPatch(
421 sharedPatch,
422 target?.serverConfig?.environment.capabilities,
423 );
424 if (Object.keys(targetPatch).length === 0) continue;
425 wroteToTarget = true;
430 void persistServerSettings({426 void persistServerSettings({
431 environmentId: targetId,427 environmentId: targetId,
432 input: { patch: sharedPatch },428 input: { patch: targetPatch },
433 });429 });
434 }430 }
431 if (!wroteToTarget) {
432 warnUnsaved(
433 targets.size > 0 ? "Update older servers to save this setting." : undefined,
434 );
435 }
435 }436 }
436 }437 }
437 if (Object.keys(clientPatch).length > 0) {438 if (Object.keys(clientPatch).length > 0) {
438 persistClientSettingsPatch(clientPatch);439 persistClientSettingsPatch(clientPatch);
439 }440 }
440 },441 },
441 [environmentId, persistServerSettings, sharedSettingsSyncTargetIds],442 [environmentId, environments, persistServerSettings],
442 );443 );
443444
444 return updateSettings;445 return updateSettings;
...@@ -453,6 +454,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -453,6 +454,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
453export function useSharedSettingsSync() {454export function useSharedSettingsSync() {
454 const primaryEnvironment = usePrimaryEnvironment();455 const primaryEnvironment = usePrimaryEnvironment();
455 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;456 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
457 const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities;
456 // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls458 // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls
457 // back to defaults while the primary is disconnected, and "apply to all"459 // back to defaults while the primary is disconnected, and "apply to all"
458 // must never push defaults over real values. Same for a primary too old to460 // must never push defaults over real values. Same for a primary too old to
...@@ -472,28 +474,35 @@ export function useSharedSettingsSync() {...@@ -472,28 +474,35 @@ export function useSharedSettingsSync() {
472 findSharedSettingsMismatches({474 findSharedSettingsMismatches({
473 primaryEnvironmentId,475 primaryEnvironmentId,
474 primarySettings,476 primarySettings,
477 primaryCapabilities,
475 environments: environments.map((environment) => ({478 environments: environments.map((environment) => ({
476 environmentId: environment.environmentId,479 environmentId: environment.environmentId,
477 label: environment.label,480 label: environment.label,
478 syncEligible: supportsSharedSettingsSync(environment),481 syncEligible: supportsSharedSettingsSync(environment),
479 settings: environment.serverConfig?.settings ?? null,482 settings: environment.serverConfig?.settings ?? null,
483 capabilities: environment.serverConfig?.environment.capabilities,
480 })),484 })),
481 }),485 }),
482 [environments, primaryEnvironmentId, primarySettings],486 [environments, primaryEnvironmentId, primarySettings, primaryCapabilities],
483 );487 );
484488
485 const applyToAll = useCallback(() => {489 const applyToAll = useCallback(() => {
486 if (primarySettings === null) {490 if (primarySettings === null) {
487 return;491 return;
488 }492 }
489 const patch = pickSharedServerSettings(primarySettings);493 const patch = pickSharedServerSettings(primarySettings, primaryCapabilities);
490 for (const mismatch of mismatches) {494 for (const mismatch of mismatches) {
495 const target = environments.find(
496 (candidate) => candidate.environmentId === mismatch.environmentId,
497 );
491 void persistServerSettings({498 void persistServerSettings({
492 environmentId: mismatch.environmentId,499 environmentId: mismatch.environmentId,
493 input: { patch },500 input: {
501 patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities),
502 },
494 });503 });
495 }504 }
496 }, [mismatches, persistServerSettings, primarySettings]);505 }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]);
497506
498 return { mismatches, applyToAll };507 return { mismatches, applyToAll };
499}508}
docs/user/updating.md+7-4
...@@ -11,12 +11,15 @@ Server updates restart the connection and can interrupt active agents and...@@ -11,12 +11,15 @@ Server updates restart the connection and can interrupt active agents and
11terminal commands. Saved threads, settings, and project files remain.11terminal commands. Saved threads, settings, and project files remain.
1212
13**Settings → General → Continue threads after restarts** is off by default.13**Settings → General → Continue threads after restarts** is off by default.
14Enable it for each environment to resume supported active threads after an14Enable it to resume supported active threads after an update, crash, or machine
15update, crash, or machine restart. T3 Code must start again on that machine;15restart. Changes are saved to connected environments that support this setting;
16update older servers first. If a supported environment was offline or has a
17different value, use **Apply to all** in Settings after it connects.
18T3 Code must start again on that machine;
16the setting does not enable automatic startup. Terminal commands may still be19the setting does not enable automatic startup. Terminal commands may still be
17interrupted, and threads without saved provider resume state need a new message.20interrupted, and threads without saved provider resume state need a new message.
18If you previously enabled continuation for updates, enable this environment21If you previously enabled continuation for updates, enable this setting once
19setting once to allow recovery without a connected client.22to allow recovery without a connected client.
2023
21## Update a connected server24## Update a connected server
2225
packages/client-runtime/src/state/sharedSettings.test.ts+109-2
...@@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";...@@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
33
4import {4import {
5 filterSharedServerPatch,
5 findSharedSettingsMismatches,6 findSharedSettingsMismatches,
6 pickSharedServerSettings,7 pickSharedServerSettings,
7 splitSharedServerPatch,8 splitSharedServerPatch,
...@@ -11,6 +12,7 @@ import {...@@ -11,6 +12,7 @@ import {
11const primaryId = EnvironmentId.make("env-primary");12const primaryId = EnvironmentId.make("env-primary");
12const laptopId = EnvironmentId.make("env-laptop");13const laptopId = EnvironmentId.make("env-laptop");
13const boxId = EnvironmentId.make("env-box");14const boxId = EnvironmentId.make("env-box");
15const restartCapabilities = { threadRestartContinuation: true };
1416
15describe("supportsSharedSettingsSync", () => {17describe("supportsSharedSettingsSync", () => {
16 it("accepts only connected servers that advertise the shared-settings capability", () => {18 it("accepts only connected servers that advertise the shared-settings capability", () => {
...@@ -40,16 +42,24 @@ describe("splitSharedServerPatch", () => {...@@ -40,16 +42,24 @@ describe("splitSharedServerPatch", () => {
40 const { sharedPatch, localPatch } = splitSharedServerPatch({42 const { sharedPatch, localPatch } = splitSharedServerPatch({
41 sidebarAutoSettleAfterDays: 7,43 sidebarAutoSettleAfterDays: 7,
42 sidebarAutoSettleOnMerge: false,44 sidebarAutoSettleOnMerge: false,
45 continueThreadsAfterServerUpdate: true,
43 enableAgentBrowserAccess: false,46 enableAgentBrowserAccess: false,
44 });47 });
45 expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false });48 expect(sharedPatch).toEqual({
49 sidebarAutoSettleAfterDays: 7,
50 sidebarAutoSettleOnMerge: false,
51 continueThreadsAfterServerUpdate: true,
52 });
46 expect(localPatch).toEqual({ enableAgentBrowserAccess: false });53 expect(localPatch).toEqual({ enableAgentBrowserAccess: false });
47 });54 });
48});55});
4956
50describe("pickSharedServerSettings", () => {57describe("pickSharedServerSettings", () => {
51 it("returns only the shared keys", () => {58 it("returns only the shared keys", () => {
52 expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([59 expect(
60 Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
61 ).toEqual([
62 "continueThreadsAfterServerUpdate",
53 "defaultThreadEnvMode",63 "defaultThreadEnvMode",
54 "newWorktreesStartFromOrigin",64 "newWorktreesStartFromOrigin",
55 "sidebarAutoSettleAfterDays",65 "sidebarAutoSettleAfterDays",
...@@ -59,9 +69,106 @@ describe("pickSharedServerSettings", () => {...@@ -59,9 +69,106 @@ describe("pickSharedServerSettings", () => {
59 });69 });
60});70});
6171
72describe("filterSharedServerPatch", () => {
73 it.each([true, false])("preserves supported restart preference %s", (enabled) => {
74 const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 };
75 expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch);
76 });
77
78 it.each([undefined, {}, { threadRestartContinuation: false }])(
79 "omits only the unsupported restart preference with capabilities %j",
80 (capabilities) => {
81 expect(
82 filterSharedServerPatch(
83 { continueThreadsAfterServerUpdate: true, sidebarAutoSettleAfterDays: 7 },
84 capabilities,
85 ),
86 ).toEqual({ sidebarAutoSettleAfterDays: 7 });
87 expect(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, capabilities)).not.toHaveProperty(
88 "continueThreadsAfterServerUpdate",
89 );
90 },
91 );
92});
93
62describe("findSharedSettingsMismatches", () => {94describe("findSharedSettingsMismatches", () => {
63 const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 };95 const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 };
6496
97 it.each([true, false])(
98 "detects remote restart continuation drift when the preference is %s",
99 (enabled) => {
100 const settings = { ...primarySettings, continueThreadsAfterServerUpdate: enabled };
101 const remoteSettings = { ...settings, continueThreadsAfterServerUpdate: !enabled };
102 const environment = {
103 environmentId: boxId,
104 label: "Remote Box",
105 syncEligible: true,
106 settings: remoteSettings,
107 capabilities: restartCapabilities,
108 };
109 expect(
110 findSharedSettingsMismatches({
111 primaryEnvironmentId: primaryId,
112 primarySettings: settings,
113 primaryCapabilities: restartCapabilities,
114 environments: [environment],
115 }),
116 ).toEqual([{ environmentId: boxId, label: "Remote Box" }]);
117 expect(
118 findSharedSettingsMismatches({
119 primaryEnvironmentId: primaryId,
120 primarySettings: settings,
121 primaryCapabilities: restartCapabilities,
122 environments: [
123 {
124 ...environment,
125 settings: Object.assign(
126 {},
127 remoteSettings,
128 pickSharedServerSettings(settings, restartCapabilities),
129 ),
130 },
131 ],
132 }),
133 ).toEqual([]);
134 },
135 );
136
137 it.each([
138 [undefined, restartCapabilities],
139 [restartCapabilities, undefined],
140 [undefined, undefined],
141 ])(
142 "ignores restart drift unless both servers support it (%j, %j)",
143 (primaryCapabilities, capabilities) => {
144 const environment = {
145 environmentId: boxId,
146 label: "Remote Box",
147 syncEligible: true,
148 capabilities,
149 settings: { ...primarySettings, continueThreadsAfterServerUpdate: true },
150 };
151 const input = {
152 primaryEnvironmentId: primaryId,
153 primarySettings,
154 primaryCapabilities,
155 environments: [environment],
156 };
157 expect(findSharedSettingsMismatches(input)).toEqual([]);
158 expect(
159 findSharedSettingsMismatches({
160 ...input,
161 environments: [
162 {
163 ...environment,
164 settings: { ...environment.settings, sidebarAutoSettleAfterDays: 14 },
165 },
166 ],
167 }),
168 ).toEqual([{ environmentId: boxId, label: "Remote Box" }]);
169 },
170 );
171
65 it("lists sync-eligible environments whose shared settings differ", () => {172 it("lists sync-eligible environments whose shared settings differ", () => {
66 const mismatches = findSharedSettingsMismatches({173 const mismatches = findSharedSettingsMismatches({
67 primaryEnvironmentId: primaryId,174 primaryEnvironmentId: primaryId,
packages/client-runtime/src/state/sharedSettings.ts+34-8
...@@ -21,6 +21,7 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";...@@ -21,6 +21,7 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
2121
22/** Server keys that hold a user preference rather than machine config. */22/** Server keys that hold a user preference rather than machine config. */
23export const SHARED_SERVER_SETTING_KEYS = [23export const SHARED_SERVER_SETTING_KEYS = [
24 "continueThreadsAfterServerUpdate",
24 "sidebarAutoSettleAfterDays",25 "sidebarAutoSettleAfterDays",
25 "sidebarAutoSettleOnMerge",26 "sidebarAutoSettleOnMerge",
26 "defaultThreadEnvMode",27 "defaultThreadEnvMode",
...@@ -52,15 +53,27 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): {...@@ -52,15 +53,27 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): {
52 };53 };
53}54}
5455
55/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */56/** Omit restart recovery on servers that cannot persist its preference. */
56export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch {57export function filterSharedServerPatch(
57 return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS);58 patch: ServerSettingsPatch,
59 capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined,
60): ServerSettingsPatch {
61 return capabilities?.threadRestartContinuation === true
62 ? patch
63 : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]);
64}
65
66/** The shared subset supported by one environment. */
67export function pickSharedServerSettings(
68 settings: ServerSettings,
69 capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">,
70): ServerSettingsPatch {
71 return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities);
58}72}
5973
60/**74/**
61 * Whether an environment can participate in shared-settings sync right now.75 * Whether an environment can participate in shared-settings sync right now.
62 * Auto-settlement is the newest feature backed by a shared key, so a server76 * Auto-settlement establishes baseline support; newer preferences are filtered separately.
63 * advertising `threadAutoSettlement` can hold every shared key.
64 */77 */
65export function supportsSharedSettingsSync(environment: {78export function supportsSharedSettingsSync(environment: {
66 readonly connection: { readonly phase: EnvironmentConnectionPhase };79 readonly connection: { readonly phase: EnvironmentConnectionPhase };
...@@ -81,12 +94,15 @@ export interface SharedSettingsEnvironment {...@@ -81,12 +94,15 @@ export interface SharedSettingsEnvironment {
81 readonly label: string;94 readonly label: string;
82 readonly syncEligible: boolean;95 readonly syncEligible: boolean;
83 readonly settings: ServerSettings | null;96 readonly settings: ServerSettings | null;
97 readonly capabilities?:
98 | Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">
99 | undefined;
84}100}
85101
86/**102/**
87 * Shared-settings sync targets whose values differ from the primary103 * Shared-settings sync targets whose values differ from the primary
88 * environment's. Other environments are skipped: nothing can be read from or104 * environment's. Other environments are skipped: nothing can be read from or
89 * written to them, or their server cannot hold every shared key. With no105 * written to them, or their server lacks baseline shared-settings support. With no
90 * primary settings loaded there is nothing to compare against, so nothing is106 * primary settings loaded there is nothing to compare against, so nothing is
91 * reported. Callers must pass the real loaded settings, never a default107 * reported. Callers must pass the real loaded settings, never a default
92 * fallback, or "apply to all" would push defaults over real values.108 * fallback, or "apply to all" would push defaults over real values.
...@@ -94,12 +110,18 @@ export interface SharedSettingsEnvironment {...@@ -94,12 +110,18 @@ export interface SharedSettingsEnvironment {
94export function findSharedSettingsMismatches(input: {110export function findSharedSettingsMismatches(input: {
95 readonly primaryEnvironmentId: EnvironmentId | null;111 readonly primaryEnvironmentId: EnvironmentId | null;
96 readonly primarySettings: ServerSettings | null;112 readonly primarySettings: ServerSettings | null;
113 readonly primaryCapabilities?:
114 | Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">
115 | undefined;
97 readonly environments: ReadonlyArray<SharedSettingsEnvironment>;116 readonly environments: ReadonlyArray<SharedSettingsEnvironment>;
98}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> {117}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> {
99 if (input.primaryEnvironmentId === null || input.primarySettings === null) {118 if (input.primaryEnvironmentId === null || input.primarySettings === null) {
100 return [];119 return [];
101 }120 }
102 const expected = pickSharedServerSettings(input.primarySettings);121 const primarySettings = pickSharedServerSettings(
122 input.primarySettings,
123 input.primaryCapabilities,
124 );
103 return input.environments.flatMap((environment) => {125 return input.environments.flatMap((environment) => {
104 if (126 if (
105 environment.environmentId === input.primaryEnvironmentId ||127 environment.environmentId === input.primaryEnvironmentId ||
...@@ -108,7 +130,11 @@ export function findSharedSettingsMismatches(input: {...@@ -108,7 +130,11 @@ export function findSharedSettingsMismatches(input: {
108 ) {130 ) {
109 return [];131 return [];
110 }132 }
111 const actual = pickSharedServerSettings(environment.settings);133 const expected = filterSharedServerPatch(primarySettings, environment.capabilities);
134 const actual = filterSharedServerPatch(
135 pickSharedServerSettings(environment.settings, environment.capabilities),
136 input.primaryCapabilities,
137 );
112 return Equal.equals(actual, expected)138 return Equal.equals(actual, expected)
113 ? []139 ? []
114 : [{ environmentId: environment.environmentId, label: environment.label }];140 : [{ environmentId: environment.environmentId, label: environment.label }];
packages/contracts/src/environment.ts+2
...@@ -94,6 +94,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -94,6 +94,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
94 threadSettlement: Schema.optionalKey(Schema.Boolean),94 threadSettlement: Schema.optionalKey(Schema.Boolean),
95 /** Server evaluates merge and inactivity settlement without a client. */95 /** Server evaluates merge and inactivity settlement without a client. */
96 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),96 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
97 /** Server persists the opt-in for continuing interrupted threads after restarts. */
98 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
97 /** Server understands thread.snooze / thread.unsnooze commands. Same99 /** Server understands thread.snooze / thread.unsnooze commands. Same
98 version-skew contract as threadSettlement. */100 version-skew contract as threadSettlement. */
99 threadSnooze: Schema.optionalKey(Schema.Boolean),101 threadSnooze: Schema.optionalKey(Schema.Boolean),