fix(settings): harden case sync (privacy, concurrent saves, failures, enable/disable)

- Redact caseSettings for non-admins and never apply a shared map on a read-only page (CASE-SYNC-001)
- Send one case per save and merge it inside mutateSettingsFile, so devices cannot discard each other's cases; rapid saves keep their order (CASE-SYNC-002)
- Check HTTP status and the response body, keep the local choice, report an unsynced save and reuse the forbidden-write handling (CASE-SYNC-003)
- Let the server decide whether sync is on: refresh the cached switch, discard stale refreshes (CASE-SYNC-004)
- Carry shared options through case rename and drop them on delete or unlink
- Give the switch an accessible name; add server, client and two-browser tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn SatieandClaude Sonnet 5.5 committed 2026-11-09 08:43:46 +08:00
1 parent d62b11586b
commit a50d0db283
12 files changed
+554 -77

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
---
"aicodeman": minor
'aicodeman': minor
---
Optional "Sync cases across devices" setting (Settings, off by default): keeps each case's Agent Teams and Opus 1M options on the server instead of in each browser, and switches a device to the case you last chose elsewhere when you return to its tab.
+1
View File
@@ -51,6 +51,7 @@ export const BROWSER_TEST_GLOBS = [
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
'test/edit-history.browser.test.ts',
'test/sync-case-state.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
+1 -1
View File
@@ -230,7 +230,7 @@ on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-o
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Allow network access | Starts new local Codex sessions (Run menu, Resume and the API; not scheduled jobs) with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies to every such session, and those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set: see [Security](Security#what-codeman-does-not-protect-against). It applies where Codex runs the folder in workspace-write (a project you trusted in Codex) and does not change Codex's sandbox mode: a folder Codex runs read-only keeps the network closed, and Codex asks for approval per command. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. |
| Animated status effects | Cosmetic. |
| Sync cases across devices | Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither writes nor follows them. A device that already has options for a case keeps them until a device changes that case's options while this is on. |
| Sync cases across devices | Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device and says so. If another device turns this off, this one stops following. A device that already has options for a case keeps them until a device changes that case's options while this is on. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
### Notifications
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview Pure helpers for the opt-in shared per-case run options (`caseSettings` in
* settings.json, written while `syncCaseState` is on). They run inside `mutateSettingsFile()`, so
* every change is serialised with the file's other writers: two devices saving different cases
* both land, where a replacement map would let the second discard the first.
*/
export interface CaseRunOptions {
agentTeams: boolean;
opusContext1m: boolean;
}
/** Most cases the shared map holds, counted after a merge (the schema bounds one request, not the file). */
export const MAX_SHARED_CASES = 500;
export class SharedCaseLimitError extends Error {
constructor() {
super(`Shared case options are limited to ${MAX_SHARED_CASES} cases`);
this.name = 'SharedCaseLimitError';
}
}
function asMap(value: unknown): Record<string, CaseRunOptions> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, CaseRunOptions>) : {};
}
/** The stored map with `incoming` merged in per case. Throws SharedCaseLimitError past the limit. */
export function mergeCaseSettings(current: unknown, incoming: Record<string, CaseRunOptions>) {
const merged = { ...asMap(current), ...incoming };
if (Object.keys(merged).length > MAX_SHARED_CASES) throw new SharedCaseLimitError();
return merged;
}
/** The map with `from` moved to `to`, or null when there is nothing to move. */
export function renameCaseSettings(current: unknown, from: string, to: string) {
const map = asMap(current);
if (!Object.hasOwn(map, from)) return null;
const { [from]: moved, ...rest } = map;
return { ...rest, [to]: moved };
}
/** The map without `name`, or null when it has no entry for it. */
export function dropCaseSettings(current: unknown, name: string) {
const map = asMap(current);
if (!Object.hasOwn(map, name)) return null;
const { [name]: _gone, ...rest } = map;
return rest;
}
+1 -1
View File
@@ -3577,7 +3577,7 @@
<span class="set-row-label">Sync cases across devices</span>
<span class="set-row-desc">Keeps each case's Agent Teams and Opus 1M options on the server and switches this device to the case you last chose on another one when you return to the tab. Off by default: each browser keeps its own. The case names are stored in the shared settings file.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState"><span class="slider"></span></label>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState" aria-label="Sync cases across devices"><span class="slider"></span></label>
</div>
</div>
</div>
+69 -18
View File
@@ -3673,7 +3673,7 @@ Object.assign(CodemanApp.prototype, {
saveCaseSettings(caseName, settings) {
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
if (this._caseStateSyncOn()) this._pushCaseSettings(caseName, settings);
if (this._caseStateSyncOn()) this._queueCaseSync(caseName, settings);
},
// ── Opt-in case sync (`syncCaseState`): case options live on the server, the selected case follows other devices ──
@@ -3683,43 +3683,94 @@ Object.assign(CodemanApp.prototype, {
return this.loadAppSettingsFromStorage().syncCaseState === true && !this._settingsReadOnly?.();
},
async _pushCaseSettings(caseName, settings) {
/**
* Send one case's options. Saves from this device run one after another in the order they were made
* (one chain), and each carries only its own case: the server merges it into the shared map, so a save
* from another device is never overwritten. `_caseSyncPending` counts a case's saves still in flight,
* so an import of the server's copy leaves that case alone.
*/
_queueCaseSync(caseName, settings) {
const entry = { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m };
const pending = this._caseSyncPending || (this._caseSyncPending = new Map());
pending.set(caseName, (pending.get(caseName) || 0) + 1);
const run = async () => {
try {
await this._pushCaseSettings(caseName, entry);
} finally {
const left = (pending.get(caseName) || 1) - 1;
if (left > 0) pending.set(caseName, left);
else pending.delete(caseName);
}
};
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(run, run);
return this._caseSyncChain;
},
/** Why a settings write did not land, or '' when it did (HTTP status and the response's own error both count). */
async _caseSyncError(res) {
if (!res) return 'the server could not be reached';
let body = null;
try {
// PUT replaces the whole map, so merge into the server's current one rather than this device's copy.
const current = await fetch('/api/settings').then((r) => r.json()).then((env) => env?.data ?? {});
await fetch('/api/settings', {
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
} catch { /* not JSON: the status is all there is */ }
if (!res.ok) return body?.error || `HTTP ${res.status}`;
if (body && (body.success === false || body.error)) return body.error || 'the server refused it';
return '';
},
async _pushCaseSettings(caseName, entry) {
let res = null;
try {
res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseSettings: {
...(current.caseSettings || {}),
[caseName]: { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m },
},
}),
body: JSON.stringify({ caseSettings: { [caseName]: entry } }),
});
} catch (err) {
console.error('Failed to sync case settings:', err);
}
// The admin-only refusal marks this page read-only (settings-ui.js), so nothing more is sent from it.
if (await this._noteSettingsWriteRefusal?.(res)) return;
const error = await this._caseSyncError(res);
if (error) this.showToast?.(`Options for "${caseName}" saved on this device, not synced: ${error}`, 'warning');
},
/** Server copy wins: write the synced per-case options into the local keys getCaseSettings() reads. */
/** Server copy wins, except for a case this device is still saving: write its options into the local keys getCaseSettings() reads. */
_applyServerCaseSettings(server) {
if (server?.syncCaseState !== true || !server.caseSettings) return;
if (server?.syncCaseState !== true || !server.caseSettings || typeof server.caseSettings !== 'object') return;
// A non-admin never receives the map (the server redacts it) and must not apply one it was handed.
if (this._settingsReadOnly?.()) return;
try {
for (const [name, s] of Object.entries(server.caseSettings)) {
localStorage.setItem('caseSettings_' + name, JSON.stringify(s));
if (!s || typeof s !== 'object' || this._caseSyncPending?.has(name)) continue;
localStorage.setItem('caseSettings_' + name, JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true }));
}
} catch { /* storage blocked: this device keeps what it has */ }
},
/** On returning to the tab: take the other devices' case options and selected case. */
/** The server decides whether sync is on: when it says otherwise, this device's cached switch follows it. */
_adoptServerCaseSyncPreference(server) {
if (typeof server?.syncCaseState !== 'boolean') return;
const cached = this.loadAppSettingsFromStorage();
if ((cached.syncCaseState === true) === server.syncCaseState) return;
this.saveAppSettingsToStorage?.({ ...cached, syncCaseState: server.syncCaseState });
},
/** On returning to the tab: take the other devices' case options and selected case, if the server has sync on. */
async _syncCaseStateFromServer() {
if (!this._caseStateSyncOn()) return;
if (this._settingsReadOnly?.()) return;
const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1);
try {
const server = await fetch('/api/settings').then((r) => r.json()).then((env) => env?.data ?? null);
await this._caseSyncChain; // this device's own saves land first, so the copy read next includes them
const res = await fetch('/api/settings');
if (!res.ok) return;
const server = (await res.json())?.data ?? null;
if (seq !== this._caseSyncRefresh || !server) return; // a newer refresh owns the answer
this._adoptServerCaseSyncPreference(server);
if (server.syncCaseState !== true) return;
this._applyServerCaseSettings(server);
const select = document.getElementById('quickStartCase');
const last = server?.lastUsedCase;
const last = server.lastUsedCase;
if (select && last && last !== select.value && this.cases?.some((c) => c.name === last)) {
select.value = last;
this.updateDirDisplayForCase(last);
+1 -1
View File
@@ -5245,7 +5245,7 @@ Object.assign(CodemanApp.prototype, {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
const { notificationPreferences, voiceSettings, respawnPresets, runMode, caseSettings: _sharedCases, ...appSettings } = settings;
// Display settings are device-specific (mobile vs desktop) and must not be synced
// from the server: see _displaySettingKeySet().
const displayKeys = this._displaySettingKeySet();
+19 -3
View File
@@ -87,6 +87,7 @@ import {
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
import { dropCaseSettings, renameCaseSettings } from '../case-state-sync.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -1747,6 +1748,14 @@ export function registerCaseRoutes(
// a stale value is ignored at creation anyway).
// An admin deleting another user's remote/Docker case clears that owner's too.
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
// The shared run options (syncCaseState) are the admin's server-wide map: a non-admin's own case of
// the same name must leave them alone. Best-effort, like the default group.
if (isAdmin(req)) {
await mutateSettingsFile((current) => {
const rest = dropCaseSettings(current.caseSettings, name);
return rest ? { ...current, caseSettings: rest } : undefined;
}).catch(() => undefined);
}
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
for (const owner of owners) {
try {
@@ -2023,11 +2032,18 @@ export function registerCaseRoutes(
const { before, after } = await mutateSettingsFile((existing) => {
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (!order && !last) return undefined;
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
// The shared run options (syncCaseState) follow the case to its new name.
const shared = renameCaseSettings(existing.caseSettings, name, newName);
if (!order && !last && !shared) return undefined;
return {
...existing,
...(order ? { caseOrder: order } : {}),
...(last ? { lastUsedCase: last } : {}),
...(shared ? { caseSettings: shared } : {}),
};
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
const keys = ['caseOrder', 'lastUsedCase', 'caseSettings'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
+18 -4
View File
@@ -49,6 +49,7 @@ import {
SETTINGS_PATH,
} from '../route-helpers.js';
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
import { mergeCaseSettings, SharedCaseLimitError } from '../case-state-sync.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
@@ -160,8 +161,8 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase` and `caseSettings` name
* the admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
@@ -169,6 +170,7 @@ export function settingsForNonAdmin(settings: unknown): unknown {
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
delete out.caseSettings;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
@@ -1141,8 +1143,14 @@ export function registerSystemRoutes(
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
const merged = { ...existing, ...settingsToStore };
// caseSettings merges per case inside the serialised writer, so two devices saving different cases both land.
const { caseSettings: incomingCases, ...plainSettings } = settingsToStore;
const { before: existing, after } = await mutateSettingsFile((current) => ({
...current,
...plainSettings,
...(incomingCases ? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) } : {}),
}));
const merged = after ?? { ...existing, ...plainSettings };
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
@@ -1244,6 +1252,12 @@ export function registerSystemRoutes(
return {};
} catch (err) {
if (err instanceof SharedCaseLimitError) {
throw Object.assign(err, {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message),
});
}
return settingsWriteErrorResponse(err);
}
});
+120
View File
@@ -0,0 +1,120 @@
/**
* @fileoverview Opt-in case sync, server side (live server, ephemeral port, real settings.json): the shared
* per-case options merge inside the serialised settings writer, a non-admin never reads them, and a case
* rename or delete carries them along (CASE-SYNC-001, CASE-SYNC-002).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { getCasesDir } from '../src/config/cases-dir.js';
import { settingsForNonAdmin } from '../src/web/routes/system-routes.js';
import {
dropCaseSettings,
MAX_SHARED_CASES,
mergeCaseSettings,
renameCaseSettings,
} from '../src/web/case-state-sync.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const on = { agentTeams: true, opusContext1m: false };
const off = { agentTeams: false, opusContext1m: true };
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2));
const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, any>;
async function send(method: string, path: string, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, any> };
}
let n = 0;
const caseName = () => `case-sync-${process.pid}-${++n}`;
beforeAll(async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
seed({});
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
vi.restoreAllMocks();
}, 30000);
describe('shared case options', () => {
beforeEach(() => seed({ syncCaseState: true }));
it('merges concurrent saves of different cases instead of letting the second discard the first', async () => {
const [a, b, c] = await Promise.all([
send('PUT', '/api/settings', { caseSettings: { alpha: on } }),
send('PUT', '/api/settings', { caseSettings: { beta: off } }),
send('PUT', '/api/settings', { caseSettings: { gamma: on } }),
]);
expect([a.status, b.status, c.status]).toEqual([200, 200, 200]);
expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on });
});
it('keeps the other cases when one is saved again, and every other setting', async () => {
seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } });
await send('PUT', '/api/settings', { caseSettings: { alpha: off } });
expect(readSettings()).toEqual({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: off, beta: off } });
});
it('refuses a merge that takes the file past the limit and leaves it as it was', async () => {
const full = Object.fromEntries(Array.from({ length: MAX_SHARED_CASES }, (_, i) => [`c${i}`, on]));
seed({ syncCaseState: true, caseSettings: full });
const res = await send('PUT', '/api/settings', { caseSettings: { overflow: on } });
expect(res.status).toBe(400);
expect(Object.keys(readSettings().caseSettings)).toHaveLength(MAX_SHARED_CASES);
expect((await send('PUT', '/api/settings', { caseSettings: { c1: off } })).status).toBe(200);
});
it('moves the options with a renamed case, and drops them when the case is deleted', async () => {
const oldName = caseName();
const newName = caseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({ syncCaseState: true, caseOrder: [oldName], caseSettings: { [oldName]: on, keep: off } });
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
expect(renamed.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
const deleted = await send('DELETE', `/api/cases/${newName}`);
expect(deleted.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ keep: off });
});
});
describe('what a non-admin is given', () => {
it('has the shared map removed, and keeps the switch the page reads', () => {
const out = settingsForNonAdmin({
syncCaseState: true,
caseOrder: ['a'],
lastUsedCase: 'a',
caseSettings: { a: on },
agentSkillEnabled: true,
});
expect(out).toEqual({ syncCaseState: true, agentSkillEnabled: true });
});
});
describe('case map helpers', () => {
it('merge, rename and drop leave the input alone and report nothing to do as null', () => {
const map = { a: on, b: off };
expect(mergeCaseSettings(map, { c: on })).toEqual({ a: on, b: off, c: on });
expect(renameCaseSettings(map, 'a', 'z')).toEqual({ b: off, z: on });
expect(renameCaseSettings(map, 'nope', 'z')).toBeNull();
expect(dropCaseSettings(map, 'a')).toEqual({ b: off });
expect(dropCaseSettings(map, 'nope')).toBeNull();
expect(mergeCaseSettings(undefined, { a: on })).toEqual({ a: on });
expect(map).toEqual({ a: on, b: off });
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview Case sync across two devices, end to end: a real server on an ephemeral port (isolated test
* HOME) and two separate Chromium contexts (each with its own localStorage, like two devices). Saves of
* different cases from both contexts both land, the other context takes them on returning to its tab, and
* once the server says sync is off the selected case stops following.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { getCasesDir } from '../src/config/cases-dir.js';
describe('Sync cases across devices in two browsers', () => {
let server: WebServer;
let browser: Browser;
let a: Page;
let b: Page;
const one = `sync-a-${process.pid}`;
const two = `sync-b-${process.pid}`;
const base = () => `http://localhost:${server.boundPort}`;
const put = (body: unknown) =>
fetch(`${base()}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const serverSettings = async () => ((await (await fetch(`${base()}/api/settings`)).json()) as any).data;
const open = async () => {
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
await page.goto(base(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
return page;
};
const opts = (page: Page, name: string) => page.evaluate((n) => (window as any).app.getCaseSettings(n), name);
const returnToTab = (page: Page) =>
page.evaluate(async () => {
const app = (window as any).app;
await app._syncCaseStateFromServer();
});
beforeAll(async () => {
for (const name of [one, two]) mkdirSync(join(getCasesDir(), name), { recursive: true });
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
await put({ syncCaseState: true });
a = await open();
b = await open();
for (const page of [a, b]) {
await page.evaluate(async () => {
const app = (window as any).app;
await app.loadAppSettingsFromServer();
});
}
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 60000);
it("keeps both devices' saves and lets the other device take them", async () => {
await Promise.all([
a.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: true, opusContext1m: false }), one),
b.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: false, opusContext1m: false }), two),
]);
await expect
.poll(async () => Object.keys((await serverSettings()).caseSettings ?? {}).sort(), { timeout: 10000 })
.toEqual([one, two].sort());
await returnToTab(b);
await returnToTab(a);
expect(await opts(b, one)).toEqual({ agentTeams: true, opusContext1m: false });
expect(await opts(a, two)).toEqual({ agentTeams: false, opusContext1m: false });
});
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
await put({ lastUsedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastUsedCase: one });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
expect(await a.evaluate(() => (window as any).app.loadAppSettingsFromStorage().syncCaseState)).toBe(false);
});
});
+188 -48
View File
@@ -1,6 +1,7 @@
/**
* @fileoverview Opt-in case sync: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on. Evaluates the real session-ui.js.
* @fileoverview Opt-in case sync, client side: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on, send one case at a time, report a failed
* save, and obey the server's answer about whether sync is on. Evaluates the real session-ui.js.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
@@ -9,85 +10,224 @@ import { SettingsUpdateSchema } from '../src/web/schemas.js';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
function makeApp(settings: Record<string, unknown>, server: Record<string, unknown> = {}) {
type Reply = { status?: number; body?: unknown } | 'throw';
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({ body: { success: true, data: {} } })) {
const store = new Map<string, string>();
const puts: unknown[] = [];
const calls: { method: string; body: unknown }[] = [];
const toasts: string[] = [];
const select = { value: 'alpha' };
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => null, addEventListener: () => {} },
document: { getElementById: () => select, addEventListener: () => {} },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
},
fetch: async (_url: string, init?: { method?: string; body?: string }) => {
if (init?.method === 'PUT') puts.push(JSON.parse(init.body!));
return { ok: true, json: async () => ({ success: true, data: server }) };
fetch: async (url: string, init?: { method?: string; body?: string }) => {
calls.push({ method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
const r = await onFetch(url, init);
if (r === 'throw') throw new Error('offline');
const status = r.status ?? 200;
const res = { ok: status < 400, status, json: async () => r.body ?? {}, clone: () => res };
return res;
},
console,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
app.loadAppSettingsFromStorage = () => settings;
return { app, store, puts };
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
app.showToast = (m: string) => void toasts.push(m);
app.cases = [{ name: 'alpha' }, { name: 'beta' }];
app.updateDirDisplayForCase = () => {};
app.updateMobileCaseLabel = () => {};
app.updateCasePickerInput = () => {};
return { app, store, calls, toasts, select, settings };
}
describe('syncCaseState', () => {
it('is accepted by the settings schema with a per-case map, and rejects junk', () => {
expect(
SettingsUpdateSchema.safeParse({
syncCaseState: true,
caseSettings: { demo: { agentTeams: true, opusContext1m: false } },
}).success
).toBe(true);
const settle = () => new Promise((r) => setTimeout(r, 0));
const entry = { agentTeams: true, opusContext1m: false };
describe('syncCaseState schema', () => {
it('accepts the setting with a per-case map, and rejects junk', () => {
expect(SettingsUpdateSchema.safeParse({ syncCaseState: true, caseSettings: { demo: entry } }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: 'yes' } } }).success).toBe(false);
expect(
SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: true, opusContext1m: true, x: 1 } } })
.success
).toBe(false);
});
});
it('saves only to this browser while the setting is off', async () => {
const { app, store, puts } = makeApp({});
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
await new Promise((r) => setTimeout(r, 0));
describe('saving a case', () => {
it('stays on this browser while the setting is off', async () => {
const { app, store, calls } = makeApp({});
app.saveCaseSettings('demo', entry);
await settle();
expect(store.get('caseSettings_demo')).toContain('agentTeams');
expect(puts).toEqual([]);
expect(calls).toEqual([]);
});
it('pushes a merge into the server map while on, and never when settings are read-only', async () => {
const server = { caseSettings: { other: { agentTeams: false, opusContext1m: true } } };
const on = makeApp({ syncCaseState: true }, server);
on.app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: false });
await new Promise((r) => setTimeout(r, 0));
expect(on.puts).toEqual([
{
caseSettings: {
other: { agentTeams: false, opusContext1m: true },
demo: { agentTeams: true, opusContext1m: false },
},
},
]);
it('sends only that case (no read of the server map first), and nothing when read-only', async () => {
const on = makeApp({ syncCaseState: true });
on.app.saveCaseSettings('demo', entry);
await settle();
expect(on.calls).toEqual([{ method: 'PUT', body: { caseSettings: { demo: entry } } }]);
const ro = makeApp({ syncCaseState: true });
ro.app._settingsReadOnly = () => true;
ro.app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
await new Promise((r) => setTimeout(r, 0));
expect(ro.puts).toEqual([]);
ro.app.saveCaseSettings('demo', entry);
await settle();
expect(ro.calls).toEqual([]);
});
it('takes the server copy into local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings({
syncCaseState: true,
caseSettings: { demo: { agentTeams: true, opusContext1m: false } },
it('sends rapid saves one after another, in the order they were made', async () => {
let release!: () => void;
const first = new Promise<void>((r) => (release = r));
let n = 0;
const { app, calls } = makeApp({ syncCaseState: true }, async () => {
if (++n === 1) await first;
return { body: { success: true, data: {} } };
});
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual({ agentTeams: true, opusContext1m: false });
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
await settle();
expect(calls).toHaveLength(1); // the second waits for the first
release();
await app._caseSyncChain;
expect(calls.map((c) => (c.body as any).caseSettings.demo.agentTeams)).toEqual([true, false]);
});
it.each([
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
['a refusal answered with HTTP 200', { body: { success: false, error: 'settings.json is not valid' } }],
['a dropped connection', 'throw' as const],
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain('not synced');
});
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
const forbidden = { status: 403, body: { success: false, errorCode: 'FORBIDDEN' } };
const { app, toasts } = makeApp({ syncCaseState: true }, () => forbidden);
app._noteSettingsWriteRefusal = async (res: { status: number }) => {
if (res.status !== 403) return false;
app._settingsWriteForbidden = true;
return true;
};
app._settingsReadOnly = () => app._settingsWriteForbidden === true;
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(toasts).toEqual([]);
expect(app._caseStateSyncOn()).toBe(false); // nothing more is sent from this page
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
it('writes the options into the local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings(server);
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual(entry);
const b = makeApp({});
b.app._applyServerCaseSettings({
syncCaseState: false,
caseSettings: { demo: { agentTeams: true, opusContext1m: false } },
b.app._applyServerCaseSettings({ ...server, syncCaseState: false });
expect(b.store.size).toBe(0);
});
it('never applies a map to a read-only identity, even one the server handed over', () => {
const { app, store } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
app._applyServerCaseSettings(server);
expect(store.size).toBe(0);
});
it('leaves a case alone while this device is still saving it', async () => {
let release!: () => void;
const hold = new Promise<void>((r) => (release = r));
const { app, store } = makeApp({ syncCaseState: true }, async () => {
await hold;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
app._applyServerCaseSettings({ syncCaseState: true, caseSettings: { demo: entry, other: entry } });
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual({ agentTeams: false, opusContext1m: false });
expect(JSON.parse(store.get('caseSettings_other')!)).toEqual(entry);
release();
await app._caseSyncChain;
});
});
describe('returning to the tab', () => {
const env = (data: Record<string, unknown>) => ({ body: { success: true, data } });
it('follows the selected case and the options while sync is on', async () => {
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: false, lastUsedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
expect(store.size).toBe(0);
expect(settings.syncCaseState).toBe(false);
expect(app._caseStateSyncOn()).toBe(false);
});
it('starts following when another device turned sync on', async () => {
const { app, select, settings } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
await app._syncCaseStateFromServer();
expect(settings.syncCaseState).toBe(true);
expect(select.value).toBe('beta');
});
it('does nothing for a read-only identity', async () => {
const { app, calls } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
});
it('keeps what it has when the request fails', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () => ({ status: 500, body: { error: 'x' } }));
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('discards an older refresh that answers after a newer one', async () => {
const releases: (() => void)[] = [];
const answers = [
env({ syncCaseState: true, lastUsedCase: 'beta' }), // the older request
env({ syncCaseState: true, lastUsedCase: 'alpha' }), // the newer request
];
let i = 0;
const { app, select } = makeApp({ syncCaseState: true }, async () => {
const mine = answers[i++];
await new Promise<void>((r) => releases.push(r));
return mine;
});
expect(b.store.size).toBe(0);
const older = app._syncCaseStateFromServer();
await settle();
const newer = app._syncCaseStateFromServer();
await settle();
releases[1](); // the newer answers first
await newer;
releases[0](); // then the older one arrives late
await older;
expect(select.value).toBe('alpha');
});
});