Skip to content

Commit ba2e4d2

Browse files
committed
fix(settings): skip restart sync on unsupported servers
1 parent 04c7cfe commit ba2e4d2

8 files changed

Lines changed: 152 additions & 36 deletions

File tree

‎apps/mobile/src/features/settings/SettingsRouteScreen.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
type ServerSettingsPatch,
4545
} from "@t3tools/contracts";
4646
import {
47+
filterSharedServerPatch,
4748
findSharedSettingsMismatches,
4849
pickSharedServerSettings,
4950
supportsSharedSettingsSync,
@@ -584,11 +585,13 @@ function AutoSettleSettingsRows() {
584585
const mismatches = findSharedSettingsMismatches({
585586
primaryEnvironmentId: reference.environmentId,
586587
primarySettings: referenceSettings,
588+
primaryCapabilities: reference.serverConfig?.environment.capabilities,
587589
environments: environments.map((environment) => ({
588590
environmentId: environment.environmentId,
589591
label: environment.label,
590592
syncEligible: supportsSharedSettingsSync(environment),
591593
settings: environment.serverConfig?.settings ?? null,
594+
capabilities: environment.serverConfig?.environment.capabilities,
592595
})),
593596
});
594597

@@ -652,11 +655,22 @@ function AutoSettleSettingsRows() {
652655
<Pressable
653656
accessibilityRole="button"
654657
onPress={() => {
655-
const patch = pickSharedServerSettings(referenceSettings);
658+
const patch = pickSharedServerSettings(
659+
referenceSettings,
660+
reference.serverConfig?.environment.capabilities,
661+
);
656662
for (const mismatch of mismatches) {
663+
const target = environments.find(
664+
(candidate) => candidate.environmentId === mismatch.environmentId,
665+
);
657666
void updateSettings({
658667
environmentId: mismatch.environmentId,
659-
input: { patch },
668+
input: {
669+
patch: filterSharedServerPatch(
670+
patch,
671+
target?.serverConfig?.environment.capabilities,
672+
),
673+
},
660674
});
661675
}
662676
}}

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export const make = Effect.gen(function* () {
219219
pullRequests: true,
220220
threadSettlement: true,
221221
threadAutoSettlement: true,
222+
threadRestartContinuation: true,
222223
threadSnooze: true,
223224
environmentThemes: true,
224225
usageLimitSources: true,

‎apps/web/src/components/settings/SettingsPanels.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2439,7 +2439,7 @@ export function GeneralSettingsPanel() {
24392439
<SettingsRow
24402440
{...searchableSetting("continue-threads-after-server-update")}
24412441
serverScoped
2442-
description="Automatically resume interrupted threads after an update, crash, or machine restart. Applies to this environment and all connected environments."
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."
24432443
resetAction={
24442444
settings.continueThreadsAfterServerUpdate !==
24452445
DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (

‎apps/web/src/hooks/useSettings.ts‎

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "@t3tools/contracts/settings";
2727
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2828
import {
29+
filterSharedServerPatch,
2930
findSharedSettingsMismatches,
3031
pickSharedServerSettings,
3132
splitSharedServerPatch,
@@ -368,18 +369,6 @@ export function usePrimarySettingsAvailable(): boolean {
368369
return primaryEnvironment !== null || !isHostedStaticApp();
369370
}
370371

371-
/** Environments that can receive a shared settings write right now. */
372-
function 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-
383372
/**
384373
* Returns an updater that routes each key to the correct backing store.
385374
*
@@ -394,7 +383,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
394383
serverEnvironment.updateSettings,
395384
"server settings update",
396385
);
397-
const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds();
386+
const { environments } = useEnvironments();
398387
const updateSettings = useCallback(
399388
(patch: UnifiedSettingsPatch) => {
400389
const { serverPatch, clientPatch } = splitPatch(patch);
@@ -419,17 +408,25 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
419408
}
420409
}
421410
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+
);
423414
if (environmentId) {
424415
targets.add(environmentId);
425416
}
426417
if (targets.size === 0) {
427418
warnUnsaved();
428419
}
429420
for (const targetId of targets) {
421+
const target = environments.find((candidate) => candidate.environmentId === targetId);
422+
const targetPatch = filterSharedServerPatch(
423+
sharedPatch,
424+
target?.serverConfig?.environment.capabilities,
425+
);
426+
if (Object.keys(targetPatch).length === 0) continue;
430427
void persistServerSettings({
431428
environmentId: targetId,
432-
input: { patch: sharedPatch },
429+
input: { patch: targetPatch },
433430
});
434431
}
435432
}
@@ -438,7 +435,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
438435
persistClientSettingsPatch(clientPatch);
439436
}
440437
},
441-
[environmentId, persistServerSettings, sharedSettingsSyncTargetIds],
438+
[environmentId, environments, persistServerSettings],
442439
);
443440

444441
return updateSettings;
@@ -453,6 +450,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
453450
export function useSharedSettingsSync() {
454451
const primaryEnvironment = usePrimaryEnvironment();
455452
const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
453+
const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities;
456454
// Read the loaded config, not `primaryServerSettingsAtom`: that atom falls
457455
// back to defaults while the primary is disconnected, and "apply to all"
458456
// must never push defaults over real values. Same for a primary too old to
@@ -472,28 +470,35 @@ export function useSharedSettingsSync() {
472470
findSharedSettingsMismatches({
473471
primaryEnvironmentId,
474472
primarySettings,
473+
primaryCapabilities,
475474
environments: environments.map((environment) => ({
476475
environmentId: environment.environmentId,
477476
label: environment.label,
478477
syncEligible: supportsSharedSettingsSync(environment),
479478
settings: environment.serverConfig?.settings ?? null,
479+
capabilities: environment.serverConfig?.environment.capabilities,
480480
})),
481481
}),
482-
[environments, primaryEnvironmentId, primarySettings],
482+
[environments, primaryEnvironmentId, primarySettings, primaryCapabilities],
483483
);
484484

485485
const applyToAll = useCallback(() => {
486486
if (primarySettings === null) {
487487
return;
488488
}
489-
const patch = pickSharedServerSettings(primarySettings);
489+
const patch = pickSharedServerSettings(primarySettings, primaryCapabilities);
490490
for (const mismatch of mismatches) {
491+
const target = environments.find(
492+
(candidate) => candidate.environmentId === mismatch.environmentId,
493+
);
491494
void persistServerSettings({
492495
environmentId: mismatch.environmentId,
493-
input: { patch },
496+
input: {
497+
patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities),
498+
},
494499
});
495500
}
496-
}, [mismatches, persistServerSettings, primarySettings]);
501+
}, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]);
497502

498503
return { mismatches, applyToAll };
499504
}

‎docs/user/updating.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ terminal commands. Saved threads, settings, and project files remain.
1212

1313
**Settings → General → Continue threads after restarts** is off by default.
1414
Enable it to resume supported active threads after an update, crash, or machine
15-
restart. Changes are saved to connected environments. If an environment was
16-
offline or has a different value, use **Apply to all** in Settings after it
17-
connects. T3 Code must start again on that machine;
15+
restart. Changes are saved to connected environments that support this setting;
16+
update older servers first. If a supported environment was offline or has a
17+
different value, use **Apply to all** in Settings after it connects.
18+
T3 Code must start again on that machine;
1819
the setting does not enable automatic startup. Terminal commands may still be
1920
interrupted, and threads without saved provider resume state need a new message.
2021
If you previously enabled continuation for updates, enable this setting once

‎packages/client-runtime/src/state/sharedSettings.test.ts‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";
22
import { describe, expect, it } from "@effect/vitest";
33

44
import {
5+
filterSharedServerPatch,
56
findSharedSettingsMismatches,
67
pickSharedServerSettings,
78
splitSharedServerPatch,
@@ -11,6 +12,7 @@ import {
1112
const primaryId = EnvironmentId.make("env-primary");
1213
const laptopId = EnvironmentId.make("env-laptop");
1314
const boxId = EnvironmentId.make("env-box");
15+
const restartCapabilities = { threadRestartContinuation: true };
1416

1517
describe("supportsSharedSettingsSync", () => {
1618
it("accepts only connected servers that advertise the shared-settings capability", () => {
@@ -54,7 +56,9 @@ describe("splitSharedServerPatch", () => {
5456

5557
describe("pickSharedServerSettings", () => {
5658
it("returns only the shared keys", () => {
57-
expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([
59+
expect(
60+
Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
61+
).toEqual([
5862
"continueThreadsAfterServerUpdate",
5963
"defaultThreadEnvMode",
6064
"newWorktreesStartFromOrigin",
@@ -65,6 +69,28 @@ describe("pickSharedServerSettings", () => {
6569
});
6670
});
6771

72+
describe("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+
6894
describe("findSharedSettingsMismatches", () => {
6995
const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 };
7096

@@ -78,29 +104,71 @@ describe("findSharedSettingsMismatches", () => {
78104
label: "Remote Box",
79105
syncEligible: true,
80106
settings: remoteSettings,
107+
capabilities: restartCapabilities,
81108
};
82109
expect(
83110
findSharedSettingsMismatches({
84111
primaryEnvironmentId: primaryId,
85112
primarySettings: settings,
113+
primaryCapabilities: restartCapabilities,
86114
environments: [environment],
87115
}),
88116
).toEqual([{ environmentId: boxId, label: "Remote Box" }]);
89117
expect(
90118
findSharedSettingsMismatches({
91119
primaryEnvironmentId: primaryId,
92120
primarySettings: settings,
121+
primaryCapabilities: restartCapabilities,
93122
environments: [
94123
{
95124
...environment,
96-
settings: Object.assign({}, remoteSettings, pickSharedServerSettings(settings)),
125+
settings: Object.assign(
126+
{},
127+
remoteSettings,
128+
pickSharedServerSettings(settings, restartCapabilities),
129+
),
97130
},
98131
],
99132
}),
100133
).toEqual([]);
101134
},
102135
);
103136

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+
104172
it("lists sync-eligible environments whose shared settings differ", () => {
105173
const mismatches = findSharedSettingsMismatches({
106174
primaryEnvironmentId: primaryId,

0 commit comments

Comments
 (0)