| author | |
| committer | |
| log | cfddb4201df8941bbfde008919da70fe5ec7552b |
| tree | d75d4518e6deb7303cdd7220b787a2f1ab3d715c |
| parent | 57626eb6eaa436b22260068d23f4e3df5f389cd1 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 119 insertions(+), 69 deletions(-)
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+9-12| ... | @@ -46,6 +46,7 @@ import { | ... | @@ -46,6 +46,7 @@ import { |
| 46 | import { | 46 | import { |
| 47 | findSharedSettingsMismatches, | 47 | findSharedSettingsMismatches, |
| 48 | pickSharedServerSettings, | 48 | pickSharedServerSettings, |
| 49 | supportsSharedSettingsSync, | ||
| 49 | } from "@t3tools/client-runtime/state/shared-settings"; | 50 | } from "@t3tools/client-runtime/state/shared-settings"; |
| 50 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 51 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 51 | import { | 52 | import { |
| ... | @@ -553,9 +554,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD | ... | @@ -553,9 +554,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD |
| 553 | 554 | ||
| 554 | /** | 555 | /** |
| 555 | * Auto-settlement is a user preference that every server has to hold. Mobile | 556 | * Auto-settlement is a user preference that every server has to hold. Mobile |
| 556 | * has no primary environment, so the first connected environment that | 557 | * has no primary environment, so the first eligible sync target provides the |
| 557 | * supports it is the reference value. Edits fan out to every connected | 558 | * reference value. Edits fan out to every eligible target, and a mismatch row |
| 558 | * environment, and a mismatch row lets the user push the reference out. | 559 | * lets the user push the reference out. |
| 559 | */ | 560 | */ |
| 560 | function AutoSettleSettingsRows() { | 561 | function AutoSettleSettingsRows() { |
| 561 | const { environments } = useEnvironments(); | 562 | const { environments } = useEnvironments(); |
| ... | @@ -564,12 +565,8 @@ function AutoSettleSettingsRows() { | ... | @@ -564,12 +565,8 @@ function AutoSettleSettingsRows() { |
| 564 | reportFailure: true, | 565 | reportFailure: true, |
| 565 | }); | 566 | }); |
| 566 | 567 | ||
| 567 | const connected = environments.filter( | 568 | const syncTargets = environments.filter(supportsSharedSettingsSync); |
| 568 | (environment) => | 569 | const reference = syncTargets[0] ?? null; |
| 569 | environment.connection.phase === "connected" && | ||
| 570 | environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, | ||
| 571 | ); | ||
| 572 | const reference = connected[0] ?? null; | ||
| 573 | const referenceSettings = reference?.serverConfig?.settings ?? null; | 570 | const referenceSettings = reference?.serverConfig?.settings ?? null; |
| 574 | 571 | ||
| 575 | const [daysDraft, setDaysDraft] = useState<string | null>(null); | 572 | const [daysDraft, setDaysDraft] = useState<string | null>(null); |
| ... | @@ -579,7 +576,7 @@ function AutoSettleSettingsRows() { | ... | @@ -579,7 +576,7 @@ function AutoSettleSettingsRows() { |
| 579 | } | 576 | } |
| 580 | 577 | ||
| 581 | const writeToAll = (patch: ServerSettingsPatch) => { | 578 | const writeToAll = (patch: ServerSettingsPatch) => { |
| 582 | for (const environment of connected) { | 579 | for (const environment of syncTargets) { |
| 583 | void updateSettings({ environmentId: environment.environmentId, input: { patch } }); | 580 | void updateSettings({ environmentId: environment.environmentId, input: { patch } }); |
| 584 | } | 581 | } |
| 585 | }; | 582 | }; |
| ... | @@ -590,7 +587,7 @@ function AutoSettleSettingsRows() { | ... | @@ -590,7 +587,7 @@ function AutoSettleSettingsRows() { |
| 590 | environments: environments.map((environment) => ({ | 587 | environments: environments.map((environment) => ({ |
| 591 | environmentId: environment.environmentId, | 588 | environmentId: environment.environmentId, |
| 592 | label: environment.label, | 589 | label: environment.label, |
| 593 | connected: environment.connection.phase === "connected", | 590 | syncEligible: supportsSharedSettingsSync(environment), |
| 594 | settings: environment.serverConfig?.settings ?? null, | 591 | settings: environment.serverConfig?.settings ?? null, |
| 595 | })), | 592 | })), |
| 596 | }); | 593 | }); |
| ... | @@ -600,7 +597,7 @@ function AutoSettleSettingsRows() { | ... | @@ -600,7 +597,7 @@ function AutoSettleSettingsRows() { |
| 600 | const draft = (daysDraft ?? "").trim(); | 597 | const draft = (daysDraft ?? "").trim(); |
| 601 | setDaysDraft(null); | 598 | setDaysDraft(null); |
| 602 | // Whole-string check so "3.5" and "3days" are rejected instead of | 599 | // Whole-string check so "3.5" and "3days" are rejected instead of |
| 603 | // silently becoming 3 on every connected environment. | 600 | // silently becoming 3 on every eligible sync target. |
| 604 | const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; | 601 | const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; |
| 605 | if ( | 602 | if ( |
| 606 | Number.isInteger(parsed) && | 603 | Number.isInteger(parsed) && |
apps/web/src/hooks/useSettings.ts+15-30| ... | @@ -29,6 +29,7 @@ import { | ... | @@ -29,6 +29,7 @@ import { |
| 29 | findSharedSettingsMismatches, | 29 | findSharedSettingsMismatches, |
| 30 | pickSharedServerSettings, | 30 | pickSharedServerSettings, |
| 31 | splitSharedServerPatch, | 31 | splitSharedServerPatch, |
| 32 | supportsSharedSettingsSync, | ||
| 32 | } from "@t3tools/client-runtime/state/shared-settings"; | 33 | } from "@t3tools/client-runtime/state/shared-settings"; |
| 33 | import { ensureLocalApi } from "~/localApi"; | 34 | import { ensureLocalApi } from "~/localApi"; |
| 34 | import { | 35 | import { |
| ... | @@ -42,11 +43,7 @@ import * as Struct from "effect/Struct"; | ... | @@ -42,11 +43,7 @@ import * as Struct from "effect/Struct"; |
| 42 | import { toastManager } from "~/components/ui/toast"; | 43 | import { toastManager } from "~/components/ui/toast"; |
| 43 | import { isHostedStaticApp } from "~/hostedPairing"; | 44 | import { isHostedStaticApp } from "~/hostedPairing"; |
| 44 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; | 45 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 45 | import { | 46 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; |
| 46 | type EnvironmentPresentation, | ||
| 47 | useEnvironments, | ||
| 48 | usePrimaryEnvironment, | ||
| 49 | } from "~/state/environments"; | ||
| 50 | import { useAtomCommand } from "~/state/use-atom-command"; | 47 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 51 | import { useTheme } from "./useTheme"; | 48 | import { useTheme } from "./useTheme"; |
| 52 | 49 | ||
| ... | @@ -332,26 +329,14 @@ export function usePrimarySettingsAvailable(): boolean { | ... | @@ -332,26 +329,14 @@ export function usePrimarySettingsAvailable(): boolean { |
| 332 | return primaryEnvironment !== null || !isHostedStaticApp(); | 329 | return primaryEnvironment !== null || !isHostedStaticApp(); |
| 333 | } | 330 | } |
| 334 | 331 | ||
| 335 | /** | ||
| 336 | * Whether an environment can hold every shared key right now. Gated on the | ||
| 337 | * auto-settlement capability because it is the newest of the shared keys: a | ||
| 338 | * server that has it has all of them. Older servers drop unknown keys on | ||
| 339 | * write, so a mismatch against them could never clear, and their decoded | ||
| 340 | * defaults must not be treated as real values. | ||
| 341 | */ | ||
| 342 | function supportsSharedSettings(environment: EnvironmentPresentation): boolean { | ||
| 343 | return ( | ||
| 344 | environment.connection.phase === "connected" && | ||
| 345 | environment.serverConfig?.environment.capabilities.threadAutoSettlement === true | ||
| 346 | ); | ||
| 347 | } | ||
| 348 | |||
| 349 | /** Environments that can receive a shared settings write right now. */ | 332 | /** Environments that can receive a shared settings write right now. */ |
| 350 | function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> { | 333 | function useSharedSettingsSyncTargetIds(): ReadonlyArray<EnvironmentId> { |
| 351 | const { environments } = useEnvironments(); | 334 | const { environments } = useEnvironments(); |
| 352 | return useMemo( | 335 | return useMemo( |
| 353 | () => | 336 | () => |
| 354 | environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), | 337 | environments |
| 338 | .filter(supportsSharedSettingsSync) | ||
| 339 | .map((environment) => environment.environmentId), | ||
| 355 | [environments], | 340 | [environments], |
| 356 | ); | 341 | ); |
| 357 | } | 342 | } |
| ... | @@ -361,16 +346,16 @@ function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> { | ... | @@ -361,16 +346,16 @@ function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> { |
| 361 | * | 346 | * |
| 362 | * Server keys are optimistically patched in atom-backed server state, then | 347 | * Server keys are optimistically patched in atom-backed server state, then |
| 363 | * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) | 348 | * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) |
| 364 | * are written to every connected environment, not only the target, so a user | 349 | * are written to every eligible sync target, not only the selected target, so |
| 365 | * preference does not silently drift between machines. Client keys go through | 350 | * a user preference does not silently drift between machines. Client keys go |
| 366 | * client persistence. | 351 | * through client persistence. |
| 367 | */ | 352 | */ |
| 368 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | 353 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 369 | const persistServerSettings = useAtomCommand( | 354 | const persistServerSettings = useAtomCommand( |
| 370 | serverEnvironment.updateSettings, | 355 | serverEnvironment.updateSettings, |
| 371 | "server settings update", | 356 | "server settings update", |
| 372 | ); | 357 | ); |
| 373 | const connectedEnvironmentIds = useConnectedEnvironmentIds(); | 358 | const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(); |
| 374 | const updateSettings = useCallback( | 359 | const updateSettings = useCallback( |
| 375 | (patch: UnifiedSettingsPatch) => { | 360 | (patch: UnifiedSettingsPatch) => { |
| 376 | const { serverPatch, clientPatch } = splitPatch(patch); | 361 | const { serverPatch, clientPatch } = splitPatch(patch); |
| ... | @@ -395,7 +380,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -395,7 +380,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 395 | } | 380 | } |
| 396 | } | 381 | } |
| 397 | if (Object.keys(sharedPatch).length > 0) { | 382 | if (Object.keys(sharedPatch).length > 0) { |
| 398 | const targets = new Set(connectedEnvironmentIds); | 383 | const targets = new Set(sharedSettingsSyncTargetIds); |
| 399 | if (environmentId) { | 384 | if (environmentId) { |
| 400 | targets.add(environmentId); | 385 | targets.add(environmentId); |
| 401 | } | 386 | } |
| ... | @@ -417,14 +402,14 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -417,14 +402,14 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 417 | }); | 402 | }); |
| 418 | } | 403 | } |
| 419 | }, | 404 | }, |
| 420 | [connectedEnvironmentIds, environmentId, persistServerSettings], | 405 | [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], |
| 421 | ); | 406 | ); |
| 422 | 407 | ||
| 423 | return updateSettings; | 408 | return updateSettings; |
| 424 | } | 409 | } |
| 425 | 410 | ||
| 426 | /** | 411 | /** |
| 427 | * Connected environments whose shared settings differ from the primary's, | 412 | * Shared-settings sync targets whose values differ from the primary's, |
| 428 | * plus an action that writes the primary's values to all of them. Drift | 413 | * plus an action that writes the primary's values to all of them. Drift |
| 429 | * happens when an environment was offline during an edit or was changed by | 414 | * happens when an environment was offline during an edit or was changed by |
| 430 | * an older client. | 415 | * an older client. |
| ... | @@ -437,7 +422,7 @@ export function useSharedSettingsSync() { | ... | @@ -437,7 +422,7 @@ export function useSharedSettingsSync() { |
| 437 | // must never push defaults over real values. Same for a primary too old to | 422 | // must never push defaults over real values. Same for a primary too old to |
| 438 | // hold the shared keys: its decoded defaults are not a source of truth. | 423 | // hold the shared keys: its decoded defaults are not a source of truth. |
| 439 | const primarySettings = | 424 | const primarySettings = |
| 440 | primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) | 425 | primaryEnvironment !== null && supportsSharedSettingsSync(primaryEnvironment) |
| 441 | ? (primaryEnvironment.serverConfig?.settings ?? null) | 426 | ? (primaryEnvironment.serverConfig?.settings ?? null) |
| 442 | : null; | 427 | : null; |
| 443 | const { environments } = useEnvironments(); | 428 | const { environments } = useEnvironments(); |
| ... | @@ -454,7 +439,7 @@ export function useSharedSettingsSync() { | ... | @@ -454,7 +439,7 @@ export function useSharedSettingsSync() { |
| 454 | environments: environments.map((environment) => ({ | 439 | environments: environments.map((environment) => ({ |
| 455 | environmentId: environment.environmentId, | 440 | environmentId: environment.environmentId, |
| 456 | label: environment.label, | 441 | label: environment.label, |
| 457 | connected: supportsSharedSettings(environment), | 442 | syncEligible: supportsSharedSettingsSync(environment), |
| 458 | settings: environment.serverConfig?.settings ?? null, | 443 | settings: environment.serverConfig?.settings ?? null, |
| 459 | })), | 444 | })), |
| 460 | }), | 445 | }), |
docs/internals/overview.md+4-3| ... | @@ -90,9 +90,10 @@ A turn is complete when its session leaves `running` status, projected by | ... | @@ -90,9 +90,10 @@ A turn is complete when its session leaves `running` status, projected by |
| 90 | does not define turn end. | 90 | does not define turn end. |
| 91 | 91 | ||
| 92 | Thread settlement is server-owned. Each server's own settings control PR and inactivity | 92 | Thread settlement is server-owned. Each server's own settings control PR and inactivity |
| 93 | settlement. Those keys are user preferences, so clients write them to every connected environment | 93 | settlement. Those keys are user preferences, so clients write them to every shared-settings sync |
| 94 | (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn | 94 | target (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and |
| 95 | when a connected environment drifts. | 95 | warn when another target drifts. A target must have an active connection and advertise the |
| 96 | `threadAutoSettlement` capability, which signals that the server can hold every shared key. | ||
| 96 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and | 97 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and |
| 97 | once per minute, including when no client is connected. It dispatches the guarded internal | 98 | once per minute, including when no client is connected. It dispatches the guarded internal |
| 98 | `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic | 99 | `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic |
docs/user/thread-sidebar.md+7-5| ... | @@ -22,11 +22,13 @@ available, the inactivity rule still applies. A manual un-settle also keeps the | ... | @@ -22,11 +22,13 @@ available, the inactivity rule still applies. A manual un-settle also keeps the |
| 22 | sorts by the moment you settled it. A thread that settled on its own sorts by its last message or | 22 | sorts by the moment you settled it. A thread that settled on its own sorts by its last message or |
| 23 | turn, not by when the server noticed it was inactive. | 23 | turn, not by when the server noticed it was inactive. |
| 24 | 24 | ||
| 25 | Change these rules in **Settings > General**. The change is written to every environment you are | 25 | Change these rules in **Settings > General**. The change is written to every connected environment |
| 26 | connected to at that moment. An environment that is offline keeps its old value. When a connected | 26 | whose server supports shared settings. An environment that is offline or needs a server update |
| 27 | environment holds a different value, **Settings > General** shows a warning that names it. Choose | 27 | keeps its old value and does not appear in mismatch warnings. When a connected environment whose |
| 28 | **Apply to all** to write your current values to every connected environment. The same applies to | 28 | server supports shared settings holds a different value, **Settings > General** shows a warning |
| 29 | the new-thread workspace mode and the source control writing style. | 29 | that names it. Choose **Apply to all** to write your current values to the environments named in |
| 30 | the warning. The same applies to the new-thread workspace mode and the source control writing | ||
| 31 | style. | ||
| 30 | 32 | ||
| 31 | A settings change affects future settlement and does not reopen a settled thread. Settings saved | 33 | A settings change affects future settlement and does not reopen a settled thread. Settings saved |
| 32 | by older clients on one device no longer control this behavior. | 34 | by older clients on one device no longer control this behavior. |
packages/client-runtime/src/state/sharedSettings.test.ts+48-9| ... | @@ -5,12 +5,36 @@ import { | ... | @@ -5,12 +5,36 @@ import { |
| 5 | findSharedSettingsMismatches, | 5 | findSharedSettingsMismatches, |
| 6 | pickSharedServerSettings, | 6 | pickSharedServerSettings, |
| 7 | splitSharedServerPatch, | 7 | splitSharedServerPatch, |
| 8 | supportsSharedSettingsSync, | ||
| 8 | } from "./sharedSettings.ts"; | 9 | } from "./sharedSettings.ts"; |
| 9 | 10 | ||
| 10 | const primaryId = EnvironmentId.make("env-primary"); | 11 | const primaryId = EnvironmentId.make("env-primary"); |
| 11 | const laptopId = EnvironmentId.make("env-laptop"); | 12 | const laptopId = EnvironmentId.make("env-laptop"); |
| 12 | const boxId = EnvironmentId.make("env-box"); | 13 | const boxId = EnvironmentId.make("env-box"); |
| 13 | 14 | ||
| 15 | describe("supportsSharedSettingsSync", () => { | ||
| 16 | it("accepts only connected servers that advertise the shared-settings capability", () => { | ||
| 17 | expect( | ||
| 18 | supportsSharedSettingsSync({ | ||
| 19 | connection: { phase: "connected" }, | ||
| 20 | serverConfig: { environment: { capabilities: { threadAutoSettlement: true } } }, | ||
| 21 | }), | ||
| 22 | ).toBe(true); | ||
| 23 | expect( | ||
| 24 | supportsSharedSettingsSync({ | ||
| 25 | connection: { phase: "connected" }, | ||
| 26 | serverConfig: { environment: { capabilities: {} } }, | ||
| 27 | }), | ||
| 28 | ).toBe(false); | ||
| 29 | expect( | ||
| 30 | supportsSharedSettingsSync({ | ||
| 31 | connection: { phase: "reconnecting" }, | ||
| 32 | serverConfig: { environment: { capabilities: { threadAutoSettlement: true } } }, | ||
| 33 | }), | ||
| 34 | ).toBe(false); | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 14 | describe("splitSharedServerPatch", () => { | 38 | describe("splitSharedServerPatch", () => { |
| 15 | it("routes preference keys to the shared patch and machine keys to the local patch", () => { | 39 | it("routes preference keys to the shared patch and machine keys to the local patch", () => { |
| 16 | const { sharedPatch, localPatch } = splitSharedServerPatch({ | 40 | const { sharedPatch, localPatch } = splitSharedServerPatch({ |
| ... | @@ -38,17 +62,27 @@ describe("pickSharedServerSettings", () => { | ... | @@ -38,17 +62,27 @@ describe("pickSharedServerSettings", () => { |
| 38 | describe("findSharedSettingsMismatches", () => { | 62 | describe("findSharedSettingsMismatches", () => { |
| 39 | const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; | 63 | const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; |
| 40 | 64 | ||
| 41 | it("lists connected environments whose shared settings differ", () => { | 65 | it("lists sync-eligible environments whose shared settings differ", () => { |
| 42 | const mismatches = findSharedSettingsMismatches({ | 66 | const mismatches = findSharedSettingsMismatches({ |
| 43 | primaryEnvironmentId: primaryId, | 67 | primaryEnvironmentId: primaryId, |
| 44 | primarySettings, | 68 | primarySettings, |
| 45 | environments: [ | 69 | environments: [ |
| 46 | { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, | 70 | { |
| 47 | { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, | 71 | environmentId: primaryId, |
| 72 | label: "Desktop", | ||
| 73 | syncEligible: true, | ||
| 74 | settings: primarySettings, | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | environmentId: laptopId, | ||
| 78 | label: "Laptop", | ||
| 79 | syncEligible: true, | ||
| 80 | settings: primarySettings, | ||
| 81 | }, | ||
| 48 | { | 82 | { |
| 49 | environmentId: boxId, | 83 | environmentId: boxId, |
| 50 | label: "Remote Box", | 84 | label: "Remote Box", |
| 51 | connected: true, | 85 | syncEligible: true, |
| 52 | settings: DEFAULT_SERVER_SETTINGS, | 86 | settings: DEFAULT_SERVER_SETTINGS, |
| 53 | }, | 87 | }, |
| 54 | ], | 88 | ], |
| ... | @@ -64,7 +98,7 @@ describe("findSharedSettingsMismatches", () => { | ... | @@ -64,7 +98,7 @@ describe("findSharedSettingsMismatches", () => { |
| 64 | { | 98 | { |
| 65 | environmentId: boxId, | 99 | environmentId: boxId, |
| 66 | label: "Remote Box", | 100 | label: "Remote Box", |
| 67 | connected: true, | 101 | syncEligible: true, |
| 68 | settings: { ...primarySettings, enableAgentBrowserAccess: false }, | 102 | settings: { ...primarySettings, enableAgentBrowserAccess: false }, |
| 69 | }, | 103 | }, |
| 70 | ], | 104 | ], |
| ... | @@ -74,7 +108,12 @@ describe("findSharedSettingsMismatches", () => { | ... | @@ -74,7 +108,12 @@ describe("findSharedSettingsMismatches", () => { |
| 74 | 108 | ||
| 75 | it("reports nothing until the primary environment's settings are loaded", () => { | 109 | it("reports nothing until the primary environment's settings are loaded", () => { |
| 76 | const environments = [ | 110 | const environments = [ |
| 77 | { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, | 111 | { |
| 112 | environmentId: boxId, | ||
| 113 | label: "Remote Box", | ||
| 114 | syncEligible: true, | ||
| 115 | settings: primarySettings, | ||
| 116 | }, | ||
| 78 | ]; | 117 | ]; |
| 79 | expect( | 118 | expect( |
| 80 | findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), | 119 | findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), |
| ... | @@ -88,7 +127,7 @@ describe("findSharedSettingsMismatches", () => { | ... | @@ -88,7 +127,7 @@ describe("findSharedSettingsMismatches", () => { |
| 88 | ).toEqual([]); | 127 | ).toEqual([]); |
| 89 | }); | 128 | }); |
| 90 | 129 | ||
| 91 | it("skips offline environments and environments without a loaded config", () => { | 130 | it("skips ineligible environments and environments without a loaded config", () => { |
| 92 | const mismatches = findSharedSettingsMismatches({ | 131 | const mismatches = findSharedSettingsMismatches({ |
| 93 | primaryEnvironmentId: primaryId, | 132 | primaryEnvironmentId: primaryId, |
| 94 | primarySettings, | 133 | primarySettings, |
| ... | @@ -96,10 +135,10 @@ describe("findSharedSettingsMismatches", () => { | ... | @@ -96,10 +135,10 @@ describe("findSharedSettingsMismatches", () => { |
| 96 | { | 135 | { |
| 97 | environmentId: laptopId, | 136 | environmentId: laptopId, |
| 98 | label: "Laptop", | 137 | label: "Laptop", |
| 99 | connected: false, | 138 | syncEligible: false, |
| 100 | settings: DEFAULT_SERVER_SETTINGS, | 139 | settings: DEFAULT_SERVER_SETTINGS, |
| 101 | }, | 140 | }, |
| 102 | { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, | 141 | { environmentId: boxId, label: "Remote Box", syncEligible: true, settings: null }, |
| 103 | ], | 142 | ], |
| 104 | }); | 143 | }); |
| 105 | expect(mismatches).toEqual([]); | 144 | expect(mismatches).toEqual([]); |
packages/client-runtime/src/state/sharedSettings.ts+36-10| ... | @@ -4,14 +4,21 @@ | ... | @@ -4,14 +4,21 @@ |
| 4 | * Every server keeps its own `settings.json`, but some keys are user | 4 | * Every server keeps its own `settings.json`, but some keys are user |
| 5 | * preferences that only live on the server because the server has to act on | 5 | * preferences that only live on the server because the server has to act on |
| 6 | * them (auto-settlement runs with no client attached). A user does not want | 6 | * them (auto-settlement runs with no client attached). A user does not want |
| 7 | * those to differ per machine. Clients write these keys to every connected | 7 | * those to differ per machine. Clients write these keys to every shared-settings |
| 8 | * environment, and warn when a connected environment still holds a different | 8 | * sync target, and warn when another target still holds a different value so |
| 9 | * value so the user can push their current value out. | 9 | * the user can push their current value out. |
| 10 | */ | 10 | */ |
| 11 | import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | 11 | import type { |
| 12 | EnvironmentId, | ||
| 13 | ExecutionEnvironmentCapabilities, | ||
| 14 | ServerSettings, | ||
| 15 | ServerSettingsPatch, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 12 | import * as Equal from "effect/Equal"; | 17 | import * as Equal from "effect/Equal"; |
| 13 | import * as Struct from "effect/Struct"; | 18 | import * as Struct from "effect/Struct"; |
| 14 | 19 | ||
| 20 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; | ||
| 21 | |||
| 15 | /** Server keys that hold a user preference rather than machine config. */ | 22 | /** Server keys that hold a user preference rather than machine config. */ |
| 16 | export const SHARED_SERVER_SETTING_KEYS = [ | 23 | export const SHARED_SERVER_SETTING_KEYS = [ |
| 17 | "sidebarAutoSettleAfterDays", | 24 | "sidebarAutoSettleAfterDays", |
| ... | @@ -50,18 +57,37 @@ export function pickSharedServerSettings(settings: ServerSettings): ServerSettin | ... | @@ -50,18 +57,37 @@ export function pickSharedServerSettings(settings: ServerSettings): ServerSettin |
| 50 | return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); | 57 | return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); |
| 51 | } | 58 | } |
| 52 | 59 | ||
| 60 | /** | ||
| 61 | * 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 server | ||
| 63 | * advertising `threadAutoSettlement` can hold every shared key. | ||
| 64 | */ | ||
| 65 | export function supportsSharedSettingsSync(environment: { | ||
| 66 | readonly connection: { readonly phase: EnvironmentConnectionPhase }; | ||
| 67 | readonly serverConfig: { | ||
| 68 | readonly environment: { | ||
| 69 | readonly capabilities: Pick<ExecutionEnvironmentCapabilities, "threadAutoSettlement">; | ||
| 70 | }; | ||
| 71 | } | null; | ||
| 72 | }): boolean { | ||
| 73 | return ( | ||
| 74 | environment.connection.phase === "connected" && | ||
| 75 | environment.serverConfig?.environment.capabilities.threadAutoSettlement === true | ||
| 76 | ); | ||
| 77 | } | ||
| 78 | |||
| 53 | export interface SharedSettingsEnvironment { | 79 | export interface SharedSettingsEnvironment { |
| 54 | readonly environmentId: EnvironmentId; | 80 | readonly environmentId: EnvironmentId; |
| 55 | readonly label: string; | 81 | readonly label: string; |
| 56 | readonly connected: boolean; | 82 | readonly syncEligible: boolean; |
| 57 | readonly settings: ServerSettings | null; | 83 | readonly settings: ServerSettings | null; |
| 58 | } | 84 | } |
| 59 | 85 | ||
| 60 | /** | 86 | /** |
| 61 | * Connected environments whose shared settings differ from the primary | 87 | * Shared-settings sync targets whose values differ from the primary |
| 62 | * environment's. Offline environments are skipped: nothing can be read from | 88 | * environment's. Other environments are skipped: nothing can be read from or |
| 63 | * or written to them, and the warning would never clear. With no primary | 89 | * written to them, or their server cannot hold every shared key. With no |
| 64 | * settings loaded there is nothing to compare against, so nothing is | 90 | * primary settings loaded there is nothing to compare against, so nothing is |
| 65 | * reported. Callers must pass the real loaded settings, never a default | 91 | * reported. Callers must pass the real loaded settings, never a default |
| 66 | * fallback, or "apply to all" would push defaults over real values. | 92 | * fallback, or "apply to all" would push defaults over real values. |
| 67 | */ | 93 | */ |
| ... | @@ -77,7 +103,7 @@ export function findSharedSettingsMismatches(input: { | ... | @@ -77,7 +103,7 @@ export function findSharedSettingsMismatches(input: { |
| 77 | return input.environments.flatMap((environment) => { | 103 | return input.environments.flatMap((environment) => { |
| 78 | if ( | 104 | if ( |
| 79 | environment.environmentId === input.primaryEnvironmentId || | 105 | environment.environmentId === input.primaryEnvironmentId || |
| 80 | !environment.connected || | 106 | !environment.syncEligible || |
| 81 | environment.settings === null | 107 | environment.settings === null |
| 82 | ) { | 108 | ) { |
| 83 | return []; | 109 | return []; |