Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopClientSettings from "./DesktopClientSettings.ts";

// Fork-only. An earlier fork build saved `anchorEnvironmentId` in the client settings file.
// The setting is gone; a file that still holds it must load (an invalid file fails the read),
// keep the user's other choices, and lose the key on the next save.
function layer(baseDir: string) {
const layerEnvironment = DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: baseDir,
platform: "darwin",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: true,
resourcesPath: "/missing/resources",
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
NodeCrypto.layer,
DesktopConfig.layerTest({ T3CODE_HOME: baseDir }),
),
),
);
return DesktopClientSettings.layer.pipe(
Layer.provideMerge(layerEnvironment),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
);
}

const withClientSettings = <A, E, R>(
effect: Effect.Effect<A, E, R | DesktopClientSettings.DesktopClientSettings>,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-client-settings-removed-keys-test-",
});
return yield* effect.pipe(Effect.provide(layer(baseDir)));
}).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped);

const weights = { "environment-anchor": 100, "environment-other": 0 };

describe("DesktopClientSettings saved with a removed fork key", () => {
it.effect.each([
{ label: "direct", wrap: (settings: object) => settings },
{ label: "wrapped", wrap: (settings: object) => ({ settings }) },
])("loads a $label document and drops the key on the next save", ({ wrap }) =>
withClientSettings(
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const settings = yield* DesktopClientSettings.DesktopClientSettings;
yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
yield* fileSystem.writeFileString(
environment.clientSettingsPath,
JSON.stringify(
wrap({
anchorEnvironmentId: "environment-anchor",
loadBalancingEnabled: true,
loadBalancingWeights: weights,
}),
),
);

const persisted = yield* settings.get;
assert.isTrue(Option.isSome(persisted));
if (Option.isNone(persisted)) return;
assert.deepStrictEqual(persisted.value, {
...DEFAULT_CLIENT_SETTINGS,
loadBalancingEnabled: true,
loadBalancingWeights: weights,
});

yield* settings.set(persisted.value);
const saved = yield* fileSystem.readFileString(environment.clientSettingsPath);
assert.notInclude(saved, "anchorEnvironmentId");
}),
),
);
});
61 changes: 61 additions & 0 deletions apps/web/src/clientPersistenceStorage.removedKeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

// Fork-only. An earlier fork build saved `anchorEnvironmentId` in the client settings.
// The setting is gone; settings that still hold it must load (an invalid document throws
// on read), keep the user's other choices, and lose the key on the next save.
const STORAGE_KEY = "t3code:client-settings:v1";

function stubWindow(): Storage {
const store = new Map<string, string>();
const localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: (index: number) => [...store.keys()][index] ?? null,
get length() {
return store.size;
},
} satisfies Storage;
vi.stubGlobal("window", { localStorage });
vi.stubGlobal("localStorage", localStorage);
return localStorage;
}

afterEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});

describe("browser client settings saved with a removed fork key", () => {
const weights = { "environment-anchor": 100, "environment-other": 0 };

it.each([
["an environment id", "environment-anchor"],
["null", null],
["a non-string", 42],
])("loads when the key holds %s and drops it on the next save", async (_label, anchor) => {
const storage = stubWindow();
storage.setItem(
STORAGE_KEY,
JSON.stringify({
anchorEnvironmentId: anchor,
loadBalancingEnabled: true,
loadBalancingWeights: weights,
}),
);
const { readBrowserClientSettings, writeBrowserClientSettings } =
await import("./clientPersistenceStorage");

const settings = readBrowserClientSettings();
expect(settings).toEqual({
...DEFAULT_CLIENT_SETTINGS,
loadBalancingEnabled: true,
loadBalancingWeights: weights,
});

writeBrowserClientSettings(settings!);
expect(JSON.parse(storage.getItem(STORAGE_KEY)!)).not.toHaveProperty("anchorEnvironmentId");
});
});
55 changes: 55 additions & 0 deletions docs/fork/anchor-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# One machine for new work (an "anchor"), with upstream load balancing

Fork note. No fork setting is needed: upstream's load balancing already expresses "start new threads of a shared
project on this machine". It does not cover threads without a project yet (see below).

## Set it up (per client: web, desktop)

1. **Settings → Environments → Load balancing**: turn on **Automatically balance load**.
2. Set the machine that should take new work to **Prefer**.
3. Set every other machine to **Manual only**. A machine you add later starts at **Normal** and joins automatic
selection, so set it to **Manual only** as well.

New threads in a project that several machines share then start on the preferred machine.

## Threads without a project: not covered yet

Until upstream fixes its issue #31048, threads without a project ignore the **Prefer** and **Manual only** weights.
They start on the primary machine (or the machine you start them from), even when it is set to **Manual only**. The
composer offers no **Auto balance**, and **Send** goes to that machine without a warning. To use another machine, pick it in the composer; its "No
project" folder opens first.

The fix exists as upstream PR #31049. The fork does not carry it yet, because it conflicts with upstream code that
is already released (upstream PR #35658). It is parked in
[scuttletoby/t3code#43](https://github.com/scuttletoby/t3code/pull/43) until upstream updates or merges it.

## What happens when (threads in a shared project)

| Situation | Result |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Preferred machine connected, with CPU and memory free | The draft shows **Auto balance** and moves there. |
| Preferred machine offline, or busy (CPU at 95% or more, or 5% memory or less) | The draft stays where it opened. **Send** refuses with "Choose a machine to continue"; pick a machine in the composer. |
| A file is attached before the draft has moved | The draft stays on the machine it opened on, and **Send** goes there, even if that machine is **Manual only** (upstream rule: attachments stay on their machine). |
| A thread is referenced that lives on a **Prefer** or **Normal** machine | That machine is the only one that can read it, so the draft moves there. |
| A thread is referenced that lives on a **Manual only** machine | No machine is picked. **Send** refuses with "Choose a machine to continue"; pick that machine in the composer. |
| A thread on another machine is referenced after the draft has moved | The draft does not move again. **Send** refuses with "Attached thread is on another machine"; remove the thread or pick its machine. |
| You pick a machine, branch or worktree by hand | That choice wins until you choose **Auto balance** again. |
| Only one machine has the project | No balancing; the draft uses that machine. |
| Load balancing off | Upstream's order: the primary machine first. |
| Thread without a project | Not balanced; see above. |

## After upstream fixes #31048

Threads without a project will then follow the same weights. The preferred machine's "No project" folder opens
first, then the draft moves. One case can still send to a machine you did not choose: if that open fails or is cut
off (for example, the connection drops while it opens), the draft settles on the machine it is on. The composer
shows **Auto balance**, and **Send** goes to that machine even if it is **Manual only**. A failed open shows "Could
not switch machine"; a cut-off open shows nothing. Pick the machine by hand if it matters. This is upstream's
behaviour in #31049; the fork does not change it.

## Removed fork setting

An earlier draft of this fork change added a client setting `anchorEnvironmentId` (**Settings → Connections →
Anchor machine**). It was removed before it shipped. Settings saved with it still load: the key is ignored, the
other settings stay, and the next save drops it (tests `clientPersistenceStorage.removedKeys.test.ts` and
`DesktopClientSettings.removedKeys.test.ts`).
Loading