fix(settings): case sync tests follow lastPickedCase, a failed pick is not undone, rollback is covered

- sync-case-state browser and stale-refresh tests use lastPickedCase (the stale test now fails without its guard)
- saveLastUsedCase runs on the case-sync chain, and the follow is skipped while this device's own pick was not stored
- _caseStateSyncOn tolerates a harness without loadAppSettingsFromStorage
- lastPickedCase added to the config-backup volatile-key test and docs; rename rollback tests cover caseSettings and lastPickedCase
- wiki row moved to System
This commit is contained in:
Devvyn committed 2026-11-09 12:31:30 +08:00
1 parent 4808ee7085
commit bb088bcd3e
9 files changed
+104 -32

No files matched your search

+1 -1
View File
@@ -872,7 +872,7 @@ Paths, agent descriptions and diff lines render via `textContent` only (browser
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
+1 -1
View File
@@ -41,7 +41,7 @@ cron jobs.
A field a file rewrites on its own is not a change, and the comparison looks through it (the
snapshot still stores the file exactly as written, and a restore puts those fields back as they
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase` and `lastPickedCase`, `intents.json`
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
`lastOpenedAt` (opening a web tab). Without that, every
+1 -2
View File
@@ -230,7 +230,6 @@ 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 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 only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. 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
@@ -259,7 +258,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
Cloudflare tunnel controls including the tunnel URL. **Sync cases across devices** (under Paths, off by default): 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 only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
+26 -15
View File
@@ -643,18 +643,29 @@ Object.assign(CodemanApp.prototype, {
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try {
// lastPickedCase is only what a person chose on a device (never a quick-start or an agent's spawn),
// so it is what other devices follow when case sync is on.
const body = this._caseStateSyncOn?.() ? { lastUsedCase: caseName, lastPickedCase: caseName } : { lastUsedCase: caseName };
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (err) {
console.error('Failed to save last used case:', err);
}
// lastPickedCase is only what a person chose on a device (never a quick-start or an agent's spawn),
// so it is what other devices follow when case sync is on.
const sync = this._caseStateSyncOn?.() === true;
const body = sync ? { lastUsedCase: caseName, lastPickedCase: caseName } : { lastUsedCase: caseName };
const send = async () => {
let stored = false;
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
stored = res?.ok !== false;
} catch (err) {
console.error('Failed to save last used case:', err);
}
// While this device's own pick is not on the server, the next tab return must not follow the server's older one.
if (sync) this._ownPickNotStored = !stored;
};
if (!sync) return send();
// On the chain the tab-return refresh already awaits, so a pick lands before the copy it reads.
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(send, send);
return this._caseSyncChain;
},
async quickStart() {
@@ -3683,7 +3694,7 @@ Object.assign(CodemanApp.prototype, {
/** Off unless the user turned it on; a non-admin in multi-user mode cannot write the shared settings.json. */
_caseStateSyncOn() {
return this.loadAppSettingsFromStorage().syncCaseState === true && !this._settingsReadOnly?.();
return this.loadAppSettingsFromStorage?.()?.syncCaseState === true && !this._settingsReadOnly?.();
},
/**
@@ -3761,7 +3772,7 @@ Object.assign(CodemanApp.prototype, {
if (this._settingsReadOnly?.()) return;
// Off on this device: no request on every tab return. Turning it on elsewhere is learned from the
// next settings load; turning it off elsewhere still reaches a device whose cached switch is on.
if (this.loadAppSettingsFromStorage().syncCaseState !== true) return;
if (this.loadAppSettingsFromStorage?.()?.syncCaseState !== true) return;
const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1);
try {
await this._caseSyncChain; // this device's own saves land first, so the copy read next includes them
@@ -3774,7 +3785,7 @@ Object.assign(CodemanApp.prototype, {
this._applyServerCaseSettings(server);
const select = document.getElementById('quickStartCase');
const last = server.lastPickedCase;
if (select && last && last !== select.value && this.cases?.some((c) => c.name === last)) {
if (select && last && !this._ownPickNotStored && last !== select.value && this.cases?.some((c) => c.name === last)) {
this._closeCaseSettingsPopovers();
this.selectQuickStartCase(last, { save: false });
}
+5 -1
View File
@@ -173,7 +173,11 @@ describe('createConfigBackup', () => {
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
[
'settings.json',
(n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}`, lastPickedCase: `case-${n}` }),
'displayName',
],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
+34 -7
View File
@@ -302,7 +302,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
const seeded = {
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: { agentTeams: true, opusContext1m: false } },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(seeded));
// the registry reads fine but cannot be written: the rename must undo the folder move
const spy = failRegistryWrite();
let res;
@@ -316,7 +322,7 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(linked()).toEqual({ proj: real });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
expect(settings()).toEqual(seeded);
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
@@ -324,17 +330,38 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
const entry = { agentTeams: true, opusContext1m: false };
writeFileSync(
SETTINGS_PATH,
JSON.stringify({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry },
})
);
// Another writer (a settings save, and a per-case save from another device) lands after the rename wrote
// settings.json and before the registry write fails.
const spy = failRegistryWrite(() =>
mutateSettingsFile((current) => ({
...current,
agentSkillEnabled: true,
caseSettings: { ...(current.caseSettings as object), other: { agentTeams: false, opusContext1m: true } },
}))
);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
expect(settings()).toEqual({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry, other: { agentTeams: false, opusContext1m: true } },
agentSkillEnabled: true,
});
});
describe('when the registry changes after the first check', () => {
-1
View File
@@ -1107,7 +1107,6 @@ describe('case selector refresh', () => {
{ name: 'kept-case', path: '/tmp/kept-case' },
];
app.showToast = vi.fn();
app.loadAppSettingsFromStorage = () => ({}); // saveLastUsedCase asks whether case sync is on
// deleteCase re-renders the case-manage list, whose path label goes through
// _shortenHomePath. That method lives in terminal-ui.js, which this harness
+2 -2
View File
@@ -75,11 +75,11 @@ describe('Sync cases across devices in two browsers', () => {
});
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
await put({ lastUsedCase: two });
await put({ lastPickedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastUsedCase: one });
await put({ syncCaseState: false, lastPickedCase: 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);
+34 -2
View File
@@ -150,6 +150,38 @@ describe('recording the picked case', () => {
});
});
describe('a pick that did not reach the server', () => {
it('is not undone by the next tab return, and following resumes once a pick is stored', async () => {
let putOk = false;
const { app, select } = makeApp({ syncCaseState: true }, (_url, init) =>
init?.method === 'PUT'
? putOk
? { body: { success: true } }
: { status: 500, body: { error: 'x' } }
: { body: { success: true, data: { syncCaseState: true, lastPickedCase: 'alpha' } } }
);
select.value = 'beta';
await app.saveLastUsedCase('beta'); // the pick fails
await app._syncCaseStateFromServer(); // the server still says alpha
expect(select.value).toBe('beta');
putOk = true;
await app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('lands before the copy a tab return reads', async () => {
const order: string[] = [];
const { app } = makeApp({ syncCaseState: true }, async (_url, init) => {
order.push(init?.method ?? 'GET');
return { body: { success: true, data: { syncCaseState: true } } };
});
void app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(order).toEqual(['PUT', 'GET']);
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
@@ -253,8 +285,8 @@ describe('returning to the tab', () => {
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
env({ syncCaseState: true, lastPickedCase: 'beta' }), // the older request
env({ syncCaseState: true, lastPickedCase: 'alpha' }), // the newer request
];
let i = 0;
const { app, select } = makeApp({ syncCaseState: true }, async () => {