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
866 changes: 866 additions & 0 deletions apps/server/src/cloud/autoUpdate.test.ts

Large diffs are not rendered by default.

830 changes: 830 additions & 0 deletions apps/server/src/cloud/autoUpdate.ts

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions apps/server/src/cloud/autoUpdateSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
import * as ServerSettingsModule from "../serverSettings.ts";
import { renameRetiredAutoUpdateKeys } from "./autoUpdateSettings.ts";

// One settings service on a fresh temp base dir per test, as serverSettings.test.ts builds it.
const layerServerSettings = () =>
ServerSettingsModule.layer.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provide(ManagedPolicy.layerTest()),
Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
Layer.provideMerge(
Layer.fresh(
ServerConfig.layerTest(process.cwd(), { prefix: "t3code-auto-update-settings-test-" }),
),
),
);

/** Loads a settings file with these contents (none when undefined) and returns what the server reads. */
const loadSettingsFile = (contents: string | undefined) =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
if (contents !== undefined) yield* fs.writeFileString(config.settingsPath, contents);
const service = yield* ServerSettingsModule.ServerSettingsService;
return yield* service.getSettings;
});

it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))(
"automatic update settings saved under the old names",
(it) => {
it.effect("loads an old file's choice under the new names, then saves only those", () =>
Effect.gen(function* () {
const settings = yield* loadSettingsFile(
JSON.stringify({
autoUpdateServer: true,
autoUpdateQuietCommand: "/usr/local/bin/quiet",
enableAgentBrowserAccess: false,
}),
);
assert.isTrue(settings.automaticUpdates);
assert.equal(settings.automaticUpdatesQuietCommand, "/usr/local/bin/quiet");
assert.isFalse(settings.enableAgentBrowserAccess);

// Any write saves the new names; the old keys are gone, so a later load reads the same.
const service = yield* ServerSettingsModule.ServerSettingsService;
yield* service.updateSettings({ enableAgentBrowserAccess: true });
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath));
assert.isTrue(persisted.automaticUpdates);
assert.equal(persisted.automaticUpdatesQuietCommand, "/usr/local/bin/quiet");
assert.notProperty(persisted, "autoUpdateServer");
assert.notProperty(persisted, "autoUpdateQuietCommand");
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("keeps a new name already in the file over the old one", () =>
Effect.gen(function* () {
const settings = yield* loadSettingsFile(
JSON.stringify({
autoUpdateServer: true,
automaticUpdates: false,
autoUpdateQuietCommand: "/old/quiet",
automaticUpdatesQuietCommand: "/new/quiet",
}),
);
assert.isFalse(settings.automaticUpdates);
assert.equal(settings.automaticUpdatesQuietCommand, "/new/quiet");
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("drops an old value of the wrong type without resetting the other settings", () =>
Effect.gen(function* () {
const settings = yield* loadSettingsFile(
JSON.stringify({
autoUpdateServer: "yes",
autoUpdateQuietCommand: 7,
enableAgentBrowserAccess: false,
}),
);
assert.isFalse(settings.automaticUpdates);
assert.equal(settings.automaticUpdatesQuietCommand, "");
assert.isFalse(settings.enableAgentBrowserAccess);
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("stays off with no settings file", () =>
Effect.gen(function* () {
const settings = yield* loadSettingsFile(undefined);
assert.isFalse(settings.automaticUpdates);
assert.equal(settings.automaticUpdatesQuietCommand, "");
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("stays off with an empty settings object", () =>
Effect.gen(function* () {
const settings = yield* loadSettingsFile("{}");
assert.isFalse(settings.automaticUpdates);
assert.equal(settings.automaticUpdatesQuietCommand, "");
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("stays off when the file was cut off half-way through an old key", () =>
Effect.gen(function* () {
const settings = yield* loadSettingsFile('{ "autoUpdateServer": tr');
assert.isFalse(settings.automaticUpdates);
assert.equal(settings.automaticUpdatesQuietCommand, "");
}).pipe(Effect.provide(layerServerSettings())),
);
},
);

it("passes through what is not a settings object, and an object without old keys unchanged", () => {
for (const value of [null, 3, "text", [true]]) {
assert.equal(renameRetiredAutoUpdateKeys(value), value);
}
const current = { automaticUpdates: true };
assert.strictEqual(renameRetiredAutoUpdateKeys(current), current);
});
26 changes: 26 additions & 0 deletions apps/server/src/cloud/autoUpdateSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Fork: settings saved before the automatic-update keys took upstream's names still say
// `autoUpdateServer` / `autoUpdateQuietCommand`. Copy them to the new names before decoding, so the
// choice loads without a schema migration; the next write saves only the new names. Delete this
// file and its call in serverSettings.ts when upstream's updater replaces ours.

const RETIRED_KEYS = [
{ retired: "autoUpdateServer", current: "automaticUpdates", type: "boolean" },
{ retired: "autoUpdateQuietCommand", current: "automaticUpdatesQuietCommand", type: "string" },
] as const;

/**
* Copies each retired key's value to its current name. A current key already in the file wins.
* A retired value of the wrong type is not copied: it would fail the whole file's decode, and a
* failed decode resets every setting to its default.
*/
export function renameRetiredAutoUpdateKeys(parsed: unknown): unknown {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return parsed;
const stored = parsed as Readonly<Record<string, unknown>>;
let renamed: Record<string, unknown> | undefined;
for (const { retired, current, type } of RETIRED_KEYS) {
const value = stored[retired];
if (stored[current] !== undefined || typeof value !== type) continue;
renamed = { ...(renamed ?? stored), [current]: value };
}
return renamed ?? parsed;
}
4 changes: 4 additions & 0 deletions apps/server/src/cloud/selfUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ export class ServerSelfUpdate extends Context.Service<
requestId: string,
onHandoffAccepted?: () => Effect.Effect<void>,
) => Effect.Effect<never, ServerSelfUpdateError>;
/** The launcher accepted a handoff but this process is still running past it: allow the
* next update (the button's or an automatic one) to start. */
readonly resetAfterDroppedHandoff: Effect.Effect<void>;
}
>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}

Expand Down Expand Up @@ -349,6 +352,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {

return ServerSelfUpdate.of({
update,
resetAfterDroppedHandoff: Ref.set(inFlight, false),
commitDesktopUpdate: Effect.fn("ServerSelfUpdate.commitDesktopUpdate")(
function (requestId, onHandoffAccepted) {
return desktopAppUpdate.commit(requestId, onHandoffAccepted);
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/EffectWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationServic
import * as ThreadManagementService from "./ThreadManagementService.ts";
import * as ServerSettings from "../serverSettings.ts";
import { continueRestartedRun } from "./RestartContinuation.ts";
import { WorkAdmission } from "../workAdmission.ts";

const isProviderTurnControlError = Schema.is(ProviderTurnControlService.ProviderTurnControlError);

Expand Down Expand Up @@ -948,6 +949,9 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions =
Effect.scoped(
Effect.gen(function* () {
const worker = yield* OrchestrationEffectWorkerV2;
const admission = yield* WorkAdmission;
// Held effects stay pending, unclaimed: startup recovery decides what they become.
yield* admission.register("outbox");
const requestedConcurrency = options.concurrency ?? DEFAULT_EFFECT_WORKER_CONCURRENCY;
const concurrency = Number.isFinite(requestedConcurrency)
? Math.max(1, Math.floor(requestedConcurrency))
Expand All @@ -962,7 +966,7 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions =
// Notifications wake promptly; availableAt persists retry timing, and liveness polling recovers missed wakeups.
const runWorker = Effect.gen(function* () {
while (true) {
const outcome = yield* worker.runOnce(claimLimits).pipe(
const outcome = yield* admission.admit(worker.runOnce(claimLimits)).pipe(
Effect.map((worked) => (worked ? ("worked" as const) : ("idle" as const))),
Effect.catchCause((cause) =>
Effect.logWarning("Orchestration effect worker failed", cause).pipe(
Expand Down
Loading
Loading