Skip to content

Commit 50daa7f

Browse files
committed
Serve settings from the paired computer, and move retention to the server
Provider settings on a phone-linked session sat on "Checking provider status" forever. The hosted app has no backend of its own, so `getPrimaryKnownEnvironment()` is null and the root route skips `ServerStateBootstrap` entirely; every settings surface reads app-level server state, which therefore stayed empty. The two provider cards on screen came from default settings, not from the paired machine. The same gap left `ensureLocalApi()` pointing at a no-backend stub, so refresh, the enable toggles, and provider actions all rejected silently. Saved environments were also frozen at their opening config snapshot: the connection layer forwarded only `snapshot` and dropped every live provider-status, keybinding, and settings event. - Fold config stream events through one shared reducer (`rpc/serverConfigEvents.ts`) used by both app-level server state and each environment's runtime record - Forward the whole config stream to saved environments, not just the first snapshot - Resolve the local API's RPC client per call, falling back to the environment in view when the app has no backend of its own, so settings and provider calls reach the paired computer - Mirror the paired computer's config into app-level state in hosted mode, and clear it when the pairing goes away Retention enforcement had the same shape of problem. `autoArchiveInactive ThreadsDays` is a server setting, but enforcement ran in the browser: nothing was enforced with no window open, every open client was a redundant enforcer, and one machine's setting was applied to another machine's threads. Mirroring config to the phone would have recruited it as one more enforcer, so the sweep moves to the server that owns the threads. - Share the archive-eligibility rule from `@threadlines/shared/threadAuto Archive` between the server sweep and the settings preview - Add `ThreadAutoArchiveSweeper`, following `ProviderSessionReaper`: sweeps at startup, every 15 minutes, and as soon as the setting changes, serialized under a semaphore so overlapping sweeps cannot double-dispatch an archive for one thread - Delete the client coordinator; the preview and manual "archive now" stay, since those are user-initiated The server has no idea which thread is on screen, so unlike the browser coordinator it cannot exempt the one you are reading. A thread already past the cutoff can be archived while open; it stays readable and unarchives in one click.
1 parent 1fc7ee6 commit 50daa7f

21 files changed

Lines changed: 971 additions & 493 deletions
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import {
2+
type OrchestrationCommand,
3+
type OrchestrationSession,
4+
type OrchestrationThreadShell,
5+
ProjectId,
6+
ProviderInstanceId,
7+
ThreadId,
8+
} from "@threadlines/contracts";
9+
import {
10+
type AutoArchiveInactiveThreadsDays,
11+
DEFAULT_SERVER_SETTINGS,
12+
type ServerSettings,
13+
} from "@threadlines/contracts/settings";
14+
import * as Effect from "effect/Effect";
15+
import * as Exit from "effect/Exit";
16+
import * as Layer from "effect/Layer";
17+
import * as ManagedRuntime from "effect/ManagedRuntime";
18+
import * as Option from "effect/Option";
19+
import * as Scope from "effect/Scope";
20+
import * as Stream from "effect/Stream";
21+
import { afterEach, describe, expect, it } from "vite-plus/test";
22+
23+
import { OrchestrationCommandInvariantError } from "../Errors.ts";
24+
import {
25+
OrchestrationEngineService,
26+
type OrchestrationEngineShape,
27+
} from "../Services/OrchestrationEngine.ts";
28+
import {
29+
ProjectionSnapshotQuery,
30+
type ProjectionSnapshotQueryShape,
31+
} from "../Services/ProjectionSnapshotQuery.ts";
32+
import { ThreadAutoArchiveSweeper } from "../Services/ThreadAutoArchiveSweeper.ts";
33+
import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
34+
import { makeThreadAutoArchiveSweeperLive } from "./ThreadAutoArchiveSweeper.ts";
35+
36+
const NOW_MS = Date.now();
37+
const PROJECT_ID = ProjectId.make("project-thread-auto-archive");
38+
const MODEL_SELECTION = {
39+
instanceId: ProviderInstanceId.make("codex"),
40+
model: "gpt-5-codex",
41+
} as const;
42+
43+
function daysAgo(days: number): string {
44+
return new Date(NOW_MS - days * 24 * 60 * 60 * 1_000).toISOString();
45+
}
46+
47+
function thread(
48+
id: string,
49+
overrides: Partial<OrchestrationThreadShell> = {},
50+
): OrchestrationThreadShell {
51+
return {
52+
id: ThreadId.make(id),
53+
projectId: PROJECT_ID,
54+
title: id,
55+
modelSelection: MODEL_SELECTION,
56+
runtimeMode: "full-access",
57+
interactionMode: "default",
58+
branch: null,
59+
worktreePath: null,
60+
effectiveCwd: null,
61+
goal: null,
62+
latestTurn: null,
63+
createdAt: daysAgo(45),
64+
updatedAt: daysAgo(45),
65+
archivedAt: null,
66+
pinnedAt: null,
67+
session: null,
68+
latestUserMessageAt: daysAgo(45),
69+
hasPendingApprovals: false,
70+
hasPendingUserInput: false,
71+
hasActionableProposedPlan: false,
72+
...overrides,
73+
};
74+
}
75+
76+
function runningSession(threadId: ThreadId): OrchestrationSession {
77+
return {
78+
threadId,
79+
status: "running",
80+
providerName: "codex",
81+
providerInstanceId: ProviderInstanceId.make("codex"),
82+
runtimeMode: "full-access",
83+
activeTurnId: null,
84+
pendingBackgroundTaskCount: 0,
85+
lastError: null,
86+
updatedAt: daysAgo(45),
87+
};
88+
}
89+
90+
function makeSnapshotQuery(
91+
threads: readonly OrchestrationThreadShell[],
92+
): ProjectionSnapshotQueryShape {
93+
const shellSnapshot = {
94+
snapshotSequence: 1,
95+
projects: [],
96+
threads: [...threads],
97+
updatedAt: new Date(NOW_MS).toISOString(),
98+
};
99+
return {
100+
getCommandReadModel: () => Effect.die("unused"),
101+
getSnapshot: () => Effect.die("unused"),
102+
getShellSnapshot: () => Effect.succeed(shellSnapshot),
103+
getArchivedShellSnapshot: () => Effect.die("unused"),
104+
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
105+
getCounts: () => Effect.die("unused"),
106+
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
107+
getProjectShellById: () => Effect.succeed(Option.none()),
108+
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
109+
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
110+
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
111+
getThreadShellById: () => Effect.succeed(Option.none()),
112+
getThreadDetailById: () => Effect.succeed(Option.none()),
113+
};
114+
}
115+
116+
describe("ThreadAutoArchiveSweeper", () => {
117+
let runtime: ManagedRuntime.ManagedRuntime<ThreadAutoArchiveSweeper, unknown> | null = null;
118+
119+
afterEach(async () => {
120+
if (runtime) {
121+
await runtime.dispose();
122+
}
123+
runtime = null;
124+
});
125+
126+
async function createHarness(input: {
127+
readonly inactiveDays: AutoArchiveInactiveThreadsDays;
128+
readonly threads: readonly OrchestrationThreadShell[];
129+
readonly failingThreadIds?: ReadonlySet<ThreadId>;
130+
readonly settingsShape?: ServerSettingsShape;
131+
}) {
132+
const dispatched: Array<Extract<OrchestrationCommand, { type: "thread.archive" }>> = [];
133+
let onDispatch: (() => void) | null = null;
134+
// Bounded so a regression that never dispatches fails in seconds instead
135+
// of hanging until the suite-wide timeout.
136+
const nextDispatch = () =>
137+
new Promise<void>((resolve, reject) => {
138+
const timer = setTimeout(() => reject(new Error("no archive dispatched")), 5_000);
139+
onDispatch = () => {
140+
clearTimeout(timer);
141+
resolve();
142+
};
143+
});
144+
const orchestrationEngine: OrchestrationEngineShape = {
145+
readEvents: () => Stream.empty,
146+
dispatch: (command) => {
147+
if (command.type !== "thread.archive") {
148+
return Effect.die(`Unexpected command: ${command.type}`);
149+
}
150+
return Effect.sync(() => {
151+
dispatched.push(command);
152+
onDispatch?.();
153+
}).pipe(
154+
Effect.andThen(
155+
input.failingThreadIds?.has(command.threadId) === true
156+
? Effect.fail(
157+
new OrchestrationCommandInvariantError({
158+
commandType: command.type,
159+
detail: "test dispatch failure",
160+
}),
161+
)
162+
: Effect.succeed({ sequence: dispatched.length }),
163+
),
164+
);
165+
},
166+
streamDomainEvents: Stream.empty,
167+
};
168+
const layer = makeThreadAutoArchiveSweeperLive({ sweepIntervalMs: 60_000 }).pipe(
169+
Layer.provideMerge(
170+
input.settingsShape === undefined
171+
? ServerSettingsService.layerTest({
172+
autoArchiveInactiveThreadsDays: input.inactiveDays,
173+
})
174+
: Layer.succeed(ServerSettingsService, input.settingsShape),
175+
),
176+
Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, makeSnapshotQuery(input.threads))),
177+
Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)),
178+
);
179+
runtime = ManagedRuntime.make(layer);
180+
const sweeper = await runtime.runPromise(Effect.service(ThreadAutoArchiveSweeper));
181+
return { dispatched, nextDispatch, sweeper };
182+
}
183+
184+
it("archives nothing when disabled", async () => {
185+
const { dispatched, sweeper } = await createHarness({
186+
inactiveDays: 0,
187+
threads: [thread("inactive")],
188+
});
189+
190+
expect(await runtime!.runPromise(sweeper.sweepNow())).toBe(0);
191+
expect(dispatched).toEqual([]);
192+
});
193+
194+
it("archives an inactive unprotected thread", async () => {
195+
const candidate = thread("inactive");
196+
const { dispatched, sweeper } = await createHarness({
197+
inactiveDays: 30,
198+
threads: [candidate],
199+
});
200+
201+
expect(await runtime!.runPromise(sweeper.sweepNow())).toBe(1);
202+
expect(dispatched).toHaveLength(1);
203+
expect(dispatched[0]).toEqual({
204+
type: "thread.archive",
205+
commandId: expect.stringContaining(`thread-auto-archive:${candidate.id}:`),
206+
threadId: candidate.id,
207+
});
208+
});
209+
210+
it("does not archive protected or recently active threads", async () => {
211+
const running = thread("running");
212+
const { dispatched, sweeper } = await createHarness({
213+
inactiveDays: 30,
214+
threads: [
215+
thread("pinned", { pinnedAt: daysAgo(40) }),
216+
{ ...running, session: runningSession(running.id) },
217+
thread("approval", { hasPendingApprovals: true }),
218+
thread("recent", { updatedAt: daysAgo(2) }),
219+
],
220+
});
221+
222+
expect(await runtime!.runPromise(sweeper.sweepNow())).toBe(0);
223+
expect(dispatched).toEqual([]);
224+
});
225+
226+
it("sweeps as soon as the retention setting turns on", async () => {
227+
const candidate = thread("inactive");
228+
const enabled: ServerSettings = {
229+
...DEFAULT_SERVER_SETTINGS,
230+
autoArchiveInactiveThreadsDays: 30,
231+
};
232+
// Starts disabled, so only the settings change can produce this archive.
233+
// `getSettings` follows the stream the way the real service does: the
234+
// current value is updated before subscribers see the change event.
235+
let current: ServerSettings = DEFAULT_SERVER_SETTINGS;
236+
const settingsShape: ServerSettingsShape = {
237+
start: Effect.void,
238+
ready: Effect.void,
239+
getSettings: Effect.sync(() => current),
240+
updateSettings: () => Effect.die("unused"),
241+
streamChanges: Stream.make(enabled).pipe(
242+
Stream.tap((settings) =>
243+
Effect.sync(() => {
244+
current = settings;
245+
}),
246+
),
247+
),
248+
};
249+
const { dispatched, nextDispatch, sweeper } = await createHarness({
250+
inactiveDays: 0,
251+
threads: [candidate],
252+
settingsShape,
253+
});
254+
255+
const scope = await runtime!.runPromise(Scope.make("sequential"));
256+
try {
257+
const archived = nextDispatch();
258+
await runtime!.runPromise(sweeper.start().pipe(Scope.provide(scope)));
259+
await archived;
260+
expect(dispatched.map((command) => command.threadId)).toEqual([candidate.id]);
261+
} finally {
262+
await runtime!.runPromise(Scope.close(scope, Exit.void));
263+
}
264+
});
265+
266+
it("continues archiving after one dispatch fails", async () => {
267+
const failing = thread("failing");
268+
const remaining = thread("remaining");
269+
const { dispatched, sweeper } = await createHarness({
270+
inactiveDays: 30,
271+
threads: [failing, remaining],
272+
failingThreadIds: new Set([failing.id]),
273+
});
274+
275+
expect(await runtime!.runPromise(sweeper.sweepNow())).toBe(1);
276+
expect(dispatched.map((command) => command.threadId)).toEqual([failing.id, remaining.id]);
277+
});
278+
});

0 commit comments

Comments
 (0)