From 37d672f4086cc3e694446b102951dcbaf73767c5 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 02:07:58 +0900 Subject: [PATCH 01/16] feat(codex): record each LazyCodex role model write with its earlier values - Every PUT that writes a role TOML appends its role, batch, and model and effort before and after to codex-agent-roles.jsonl, and GET /api/codex-agent-roles returns the newest 50 as history --- src/codex/role-model-history.ts | 70 +++++++++++++++++++ .../management/codex-agent-role-routes.ts | 28 +++++++- structure/subagents.md | 1 + tests/server/codex-agent-role-routes.test.ts | 21 +++++- 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 src/codex/role-model-history.ts diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts new file mode 100644 index 00000000000..e7f7e59e217 --- /dev/null +++ b/src/codex/role-model-history.ts @@ -0,0 +1,70 @@ +/** + * Every role model write the omo tab and `ocx agent roles set` make, so a role can be put back. + * The integration journal cannot hold these: it snapshots one client config per operation, and a + * role write spans `$CODEX_HOME/agents/.toml` and LazyCodex's omo.jsonc mirror. So the + * values are recorded rather than bytes, and a restore is an ordinary write of the earlier values. + * Writes made in one action (auto-assign's Apply all) share a batch and are restored together. + */ +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ensureDir, integrationsDir } from "../integrations/ownership"; + +export interface RoleSetting { + readonly model: string | null; + readonly effort: string | null; +} + +export interface RoleModelChange { + readonly at: string; + readonly batch: string; + readonly role: string; + readonly before: RoleSetting; + readonly after: RoleSetting; +} + +export const ROLE_HISTORY_LIMIT = 50; +const BATCH_PATTERN = /^[A-Za-z0-9-]{1,64}$/; + +function historyPath(dir: string): string { + return join(dir, "codex-agent-roles.jsonl"); +} + +export function isRoleHistoryBatch(value: unknown): value is string { + return typeof value === "string" && BATCH_PATTERN.test(value); +} + +export function recordRoleModelChange(change: RoleModelChange, dir: string = integrationsDir()): void { + ensureDir(historyPath(dir)); + appendFileSync(historyPath(dir), `${JSON.stringify(change)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +function isSetting(value: unknown): value is RoleSetting { + const setting = value as RoleSetting | null; + return typeof setting === "object" && setting !== null + && (setting.model === null || typeof setting.model === "string") + && (setting.effort === null || typeof setting.effort === "string"); +} + +function parseChange(line: string): RoleModelChange | null { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return null; + } + const change = value as RoleModelChange | null; + return typeof change === "object" && change !== null && typeof change.at === "string" && typeof change.role === "string" + && isRoleHistoryBatch(change.batch) && isSetting(change.before) && isSetting(change.after) + ? change + : null; +} + +/** Newest first. A torn or foreign line is skipped, the way the integration journal reads its own. */ +export function listRoleModelChanges(limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { + const path = historyPath(dir); + if (!existsSync(path)) return []; + return readFileSync(path, "utf8").split("\n").flatMap(line => { + const change = line.trim() ? parseChange(line) : null; + return change ? [change] : []; + }).reverse().slice(0, limit); +} diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index a3d8f813f00..34867ab0d68 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -22,11 +22,12 @@ function lazycodexNotDetected(ctx: ManagementContext): Response { export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; - const [{ getCodexHome }, roles, omo, { detectLazyCodex }] = await Promise.all([ + const [{ getCodexHome }, roles, omo, { detectLazyCodex }, history] = await Promise.all([ import("../../codex/paths"), import("../../codex/agent-role-models"), import("../../clients/omo-role-models"), import("../../clients/lazycodex"), + import("../../codex/role-model-history"), ]); if (url.pathname === "/api/codex-agent-roles" && req.method === "GET") { @@ -43,6 +44,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis effort: roles.readCodexAgentRoleEffort(entry.role, codexHome), omoJsoncModel: omoModels[entry.role] ?? null, })), + history: history.listRoleModelChanges(), }, 200, req, config); } @@ -88,7 +90,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis } catch { return jsonResponse({ error: "role must be URL-encoded", code: "invalid_role" }, 400, req, config); } - let body: { model?: unknown; effort?: unknown }; + let body: { model?: unknown; effort?: unknown; batch?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { @@ -102,12 +104,20 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (body?.model === undefined && body?.effort === undefined) { return jsonResponse({ error: "send a model, an effort, or both", code: "empty_update" }, 400, req, config); } + if (body.batch !== undefined && !history.isRoleHistoryBatch(body.batch)) { + return jsonResponse({ error: "batch must be 1 to 64 letters, digits or dashes", code: "invalid_batch" }, 400, req, config); + } let model: string | undefined; let effort: string | null | undefined; let toml: { status: "written" | "unchanged" }; + let before: import("../../codex/role-model-history").RoleSetting = { model: null, effort: null }; try { model = body?.model === undefined ? undefined : roles.validateAgentRoleModel(body.model); effort = body?.effort === undefined ? undefined : body.effort === null ? null : roles.validateAgentRoleEffort(body.effort); + before = { + model: roles.listCodexAgentRoleModels(codexHome).find(entry => entry.role === role)?.model ?? null, + effort: roles.readCodexAgentRoleEffort(role, codexHome), + }; toml = roles.writeCodexAgentRoleModel(role, model, codexHome, effort); } catch (error) { if (error instanceof roles.AgentRoleModelError) { @@ -117,6 +127,20 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis // Filesystem and ownership errors carry absolute paths and UIDs; keep them out of the response. return jsonResponse({ error: "could not write the role file", code: "write_failed" }, 500, req, config); } + if (toml.status === "written") { + try { + history.recordRoleModelChange({ + at: new Date().toISOString(), + batch: body.batch ?? crypto.randomUUID(), + role, + before, + after: { model: model ?? before.model, effort: effort === undefined ? before.effort : effort }, + }); + } catch (error) { + // The write already stands; a lost history row only costs the Restore button for it. + console.error(`[codex-agent-roles] could not record the change: ${String(error)}`); + } + } // The role TOML is what Codex obeys, so its write stands even when the omo mirror cannot follow. let omoStatus: ReturnType | "write_failed"; diff --git a/structure/subagents.md b/structure/subagents.md index 0cda574b70e..e6e174ce0ed 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,6 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, and model and effort before and after. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. `GET /api/codex-agent-roles` returns the newest 50 rows as `history` when LazyCodex is detected. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index f399c9f1f13..6852da0ab01 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -9,7 +9,7 @@ import { ManagementRequest as Request } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const ROLE = 'name = "explorer"\ndeveloper_instructions = """\nmodel = "x"\n"""\nmodel = "gpt-5.5"\n'; -const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; let root = ""; function restore(name: keyof typeof saved): void { @@ -27,12 +27,14 @@ beforeEach(() => { process.env.CODEX_HOME = join(root, "codex"); process.env.HOME = join(root, "home"); process.env.USERPROFILE = join(root, "home"); + process.env.OPENCODEX_HOME = join(root, "ocx"); }); afterEach(() => { restore("CODEX_HOME"); restore("HOME"); restore("USERPROFILE"); + restore("OPENCODEX_HOME"); removeTreeWithRetry(root); }); @@ -57,12 +59,28 @@ function put(role: string, model: unknown) { } describe("/api/codex-agent-roles", () => { + test("records each written change with its earlier values, newest first, and skips an unchanged save", async () => { + const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); + expect((await write({ model: "xai/grok-4.5", effort: "high", batch: "apply-all-1" })).status).toBe(200); + expect((await write({ model: "xai/grok-4.5", effort: "high", batch: "apply-all-2" })).body).toMatchObject({ toml: { status: "unchanged" } }); + await write({ effort: null }); + const history = (await call("/api/codex-agent-roles")).body.history as Record[]; + expect(history.map(({ batch, role, before, after }) => ({ batch, role, before, after }))).toEqual([ + { batch: expect.stringMatching(/^[0-9a-f-]{36}$/), role: "explorer", before: { model: "xai/grok-4.5", effort: "high" }, after: { model: "xai/grok-4.5", effort: null } }, + { batch: "apply-all-1", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "high" } }, + ]); + const refused = await write({ model: "gpt-5.5", batch: "../x" }); + expect(refused).toMatchObject({ status: 400, body: { code: "invalid_batch" } }); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toContain('model = "xai/grok-4.5"'); + }); + test("round-trips a role model through the TOML and omo.jsonc", async () => { writeFileSync(join(root, "home", ".omo", "omo.jsonc"), '{ "[codex]": {} }\n'); expect((await call("/api/codex-agent-roles")).body).toEqual({ lazycodex: DETECTED, omoJsonc: { state: "present" }, roles: [{ role: "explorer", model: "gpt-5.5", effort: null, omoJsoncModel: null }], + history: [], }); const saved = await put("explorer", "xai/grok-4.5"); expect(saved.status).toBe(200); @@ -274,6 +292,7 @@ describe("/api/codex-agent-roles", () => { lazycodex: DETECTED, omoJsonc: { state: "unreadable" }, roles: [{ role: "explorer", model: "gpt-5.5", effort: null, omoJsoncModel: null }], + history: [], }); const saved = await put("explorer", "m3"); expect(saved.body.toml).toEqual({ status: "written" }); From 559cb4833c0ed882acde44020921b92685dd0d70 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 02:07:58 +0900 Subject: [PATCH 02/16] feat(gui): restore LazyCodex role models from the change history - The omo role section lists the history one entry per batch and restores a batch's earlier models and efforts through the ordinary save; Apply all writes under one batch --- .../src/content/docs/guides/integrations.md | 2 + gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/pt.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/vi.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + .../integrations/LazyCodexRoleAutoAssign.tsx | 10 ++- .../integrations/LazyCodexRoleHistory.tsx | 83 +++++++++++++++++++ .../integrations/LazyCodexRoleModels.tsx | 32 +++++-- gui/tests/integrations-surfaces.test.tsx | 2 +- gui/tests/lazycodex-role-auto-assign.test.tsx | 8 +- gui/tests/lazycodex-role-models.test.tsx | 23 +++++ 18 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 gui/src/pages/integrations/LazyCodexRoleHistory.tsx diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 1cba39c7c76..5c834204b91 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,6 +912,8 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` +Under the table, **Change history** lists every change a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `codex-agent-roles.jsonl` in the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. + ## Kilo Kilo CLI, VS Code, and JetBrains share one global config. This integration writes diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3edd48748e0..487a099c34f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1905,6 +1905,10 @@ export const de: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} läuft jetzt mit {model}. omo.jsonc blieb unverändert, weil es kein gültiges JSON in der Form ist, die LazyCodex liest.", "integrations.lazycodexRoles.omoFailed": "{role} läuft jetzt mit {model}, aber omo.jsonc konnte nicht geschrieben werden.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", + "integrations.lazycodexRoles.history.title": "Änderungsverlauf", + "integrations.lazycodexRoles.history.hint": "Jedes Speichern und jede automatische Zuweisung in diesem Abschnitt wird hier aufgeführt. Wiederherstellen schreibt das frühere Modell und den früheren Aufwand wie ein normales Speichern zurück und wird ebenfalls aufgeführt. Eine Rolle ohne frühere Modellbindung behält ihr aktuelles Modell; nur der Aufwand wird zurückgesetzt.", + "integrations.lazycodexRoles.history.restore": "Wiederherstellen", + "integrations.lazycodexRoles.history.restored": "{count} Rolle(n) auf den Stand vor {time} zurückgesetzt.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Setze [codex].agents in omo.jsonc selbst.", "integrations.lazycodexRoles.auto.button": "Automatisch zuweisen", "integrations.lazycodexRoles.auto.running": "Rollen werden eingestuft…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 538167a0c02..82d570f5ff2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2536,6 +2536,10 @@ export const en = { "integrations.lazycodexRoles.omoInvalid": "{role} now runs on {model}. omo.jsonc was left alone because it is not valid JSON in the shape LazyCodex reads.", "integrations.lazycodexRoles.omoFailed": "{role} now runs on {model}, but omo.jsonc could not be written.", "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", + "integrations.lazycodexRoles.history.title": "Change history", + "integrations.lazycodexRoles.history.hint": "Each save and auto-assign in this section is listed here. Restore writes the earlier model and effort back as an ordinary save, so it is listed too. A role that had no model pin keeps its current one; only its effort goes back.", + "integrations.lazycodexRoles.history.restore": "Restore", + "integrations.lazycodexRoles.history.restored": "Restored {count} role(s) to how they were before {time}.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc has comments, so saving here updates only the role files. Set [codex].agents in omo.jsonc yourself.", "integrations.lazycodexRoles.auto.button": "Auto-assign", "integrations.lazycodexRoles.auto.running": "Sizing roles…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 772fdf9cca0..7b330acc957 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2463,6 +2463,10 @@ export const fr: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} utilise désormais {model}. omo.jsonc n'a pas été modifié, car ce n'est pas un JSON valide de la forme que LazyCodex lit.", "integrations.lazycodexRoles.omoFailed": "{role} utilise désormais {model}, mais omo.jsonc n'a pas pu être écrit.", "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", + "integrations.lazycodexRoles.history.title": "Historique des modifications", + "integrations.lazycodexRoles.history.hint": "Chaque enregistrement et chaque attribution automatique de cette section figure ici. Restaurer réécrit le modèle et l'effort précédents comme un enregistrement ordinaire, qui figure donc aussi ici. Un rôle sans modèle épinglé auparavant garde son modèle actuel ; seul son effort est rétabli.", + "integrations.lazycodexRoles.history.restore": "Restaurer", + "integrations.lazycodexRoles.history.restored": "{count} rôle(s) rétabli(s) à leur état d'avant {time}.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : enregistrer ici ne met à jour que les fichiers de rôle. Définissez [codex].agents dans omo.jsonc vous-même.", "integrations.lazycodexRoles.auto.button": "Attribution auto", "integrations.lazycodexRoles.auto.running": "Évaluation des rôles…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 067dc34a507..82a2ea66533 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2358,6 +2358,10 @@ export const ja: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} は {model} で動くようになりました。omo.jsonc は LazyCodex が読む形の有効な JSON ではないため変更していません。", "integrations.lazycodexRoles.omoFailed": "{role} は {model} で動くようになりましたが、omo.jsonc は書き込めませんでした。", "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", + "integrations.lazycodexRoles.history.title": "変更履歴", + "integrations.lazycodexRoles.history.hint": "このセクションでの保存と自動割り当てはすべてここに記録されます。元に戻すと以前のモデルと推論強度が通常の保存と同じ方法で書き戻され、その操作も記録されます。以前モデルが固定されていなかったロールは現在のモデルを保ち、推論強度だけを戻します。", + "integrations.lazycodexRoles.history.restore": "元に戻す", + "integrations.lazycodexRoles.history.restored": "{count} 件のロールを {time} より前の状態に戻しました。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが更新されます。omo.jsonc の [codex].agents はご自身で設定してください。", "integrations.lazycodexRoles.auto.button": "自動割り当て", "integrations.lazycodexRoles.auto.running": "ロールを評価中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8237f6c18c6..095a74c9f30 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1949,6 +1949,10 @@ export const ko: Record = { "integrations.lazycodexRoles.omoInvalid": "{role}은(는) 이제 {model}로 실행됩니다. omo.jsonc가 LazyCodex가 읽는 형태의 올바른 JSON이 아니어서 그 파일은 바꾸지 않았습니다.", "integrations.lazycodexRoles.omoFailed": "{role}은(는) 이제 {model}로 실행되지만, omo.jsonc는 쓰지 못했습니다.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", + "integrations.lazycodexRoles.history.title": "변경 기록", + "integrations.lazycodexRoles.history.hint": "이 섹션에서 저장하거나 자동 배정한 변경이 모두 여기에 남습니다. 되돌리기는 이전 모델과 추론 강도를 일반 저장과 같은 방식으로 다시 쓰고, 그 자체도 기록됩니다. 원래 모델이 지정되지 않았던 역할은 현재 모델을 유지하고 추론 강도만 되돌립니다.", + "integrations.lazycodexRoles.history.restore": "되돌리기", + "integrations.lazycodexRoles.history.restored": "{count}개 역할을 {time} 이전 상태로 되돌렸습니다.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 [codex].agents는 직접 맞춰 주세요.", "integrations.lazycodexRoles.auto.button": "자동 배정", "integrations.lazycodexRoles.auto.running": "역할 평가 중…", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 6a699319003..4bb6818b781 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2507,6 +2507,10 @@ export const pt: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} agora usa {model}. O omo.jsonc ficou como estava porque não é um JSON válido no formato que o LazyCodex lê.", "integrations.lazycodexRoles.omoFailed": "{role} agora usa {model}, mas não foi possível gravar o omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Tentar omo.jsonc novamente", + "integrations.lazycodexRoles.history.title": "Histórico de alterações", + "integrations.lazycodexRoles.history.hint": "Cada gravação e atribuição automática desta seção aparece aqui. Restaurar grava de volta o modelo e o esforço anteriores como uma gravação comum, que também aparece aqui. Um papel que não tinha modelo fixado mantém o atual; só o esforço volta.", + "integrations.lazycodexRoles.history.restore": "Restaurar", + "integrations.lazycodexRoles.history.restored": "{count} papel(is) restaurado(s) ao estado anterior a {time}.", "integrations.lazycodexRoles.omoCommentsState": "O omo.jsonc tem comentários, então salvar aqui atualiza só os arquivos de papel. Defina [codex].agents no omo.jsonc você mesmo.", "integrations.lazycodexRoles.auto.button": "Atribuir automaticamente", "integrations.lazycodexRoles.auto.running": "Dimensionando funções…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 028a9f775ed..10f5c15cd31 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2449,6 +2449,10 @@ export const ru: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} теперь работает на {model}. omo.jsonc не изменён: это не корректный JSON той структуры, которую читает LazyCodex.", "integrations.lazycodexRoles.omoFailed": "{role} теперь работает на {model}, но записать omo.jsonc не удалось.", "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", + "integrations.lazycodexRoles.history.title": "История изменений", + "integrations.lazycodexRoles.history.hint": "Здесь перечислены все сохранения и автоназначения в этом разделе. Восстановление записывает прежние модель и усилие как обычное сохранение и тоже попадает сюда. Роль, у которой раньше не было закреплённой модели, сохраняет текущую; возвращается только усилие.", + "integrations.lazycodexRoles.history.restore": "Восстановить", + "integrations.lazycodexRoles.history.restored": "Восстановлено ролей: {count}, состояние до {time}.", "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь обновляет только файлы ролей. Задайте [codex].agents в omo.jsonc сами.", "integrations.lazycodexRoles.auto.button": "Автоназначение", "integrations.lazycodexRoles.auto.running": "Оценка ролей…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9965c216db9..1d1adb35c73 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2475,6 +2475,10 @@ export const tr: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} artık {model} ile çalışıyor. omo.jsonc, LazyCodex'in okuduğu biçimde geçerli bir JSON olmadığı için değiştirilmedi.", "integrations.lazycodexRoles.omoFailed": "{role} artık {model} ile çalışıyor, ancak omo.jsonc yazılamadı.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", + "integrations.lazycodexRoles.history.title": "Değişiklik geçmişi", + "integrations.lazycodexRoles.history.hint": "Bu bölümdeki her kaydetme ve otomatik atama burada listelenir. Geri yükleme önceki modeli ve çabayı sıradan bir kaydetme gibi geri yazar ve o da listelenir. Önceden sabitlenmiş modeli olmayan bir rol mevcut modelini korur; yalnızca çabası geri döner.", + "integrations.lazycodexRoles.history.restore": "Geri yükle", + "integrations.lazycodexRoles.history.restored": "{count} rol {time} öncesindeki durumuna geri yüklendi.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorumlar içeriyor, bu yüzden burada kaydetmek yalnızca rol dosyalarını günceller. omo.jsonc içindeki [codex].agents değerini kendiniz ayarlayın.", "integrations.lazycodexRoles.auto.button": "Otomatik ata", "integrations.lazycodexRoles.auto.running": "Roller değerlendiriliyor…", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index c416a426462..eca1d6acbf3 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2484,6 +2484,10 @@ export const vi: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} giờ chạy bằng {model}. omo.jsonc được giữ nguyên vì nó không phải JSON hợp lệ theo dạng LazyCodex đọc.", "integrations.lazycodexRoles.omoFailed": "{role} giờ chạy bằng {model}, nhưng không ghi được omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", + "integrations.lazycodexRoles.history.title": "Lịch sử thay đổi", + "integrations.lazycodexRoles.history.hint": "Mọi lần lưu và tự động gán trong phần này đều được liệt kê ở đây. Khôi phục ghi lại mô hình và mức suy luận trước đó như một lần lưu thông thường, nên thao tác đó cũng được liệt kê. Vai trò trước đây không ghim mô hình sẽ giữ mô hình hiện tại; chỉ mức suy luận được khôi phục.", + "integrations.lazycodexRoles.history.restore": "Khôi phục", + "integrations.lazycodexRoles.history.restored": "Đã khôi phục {count} vai trò về trạng thái trước {time}.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích, nên lưu ở đây chỉ cập nhật các tệp vai trò. Hãy tự đặt [codex].agents trong omo.jsonc.", "integrations.lazycodexRoles.auto.button": "Tự động gán", "integrations.lazycodexRoles.auto.running": "Đang đánh giá vai trò…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a2d1b5a9a7c..23d390e0a8e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3396,6 +3396,10 @@ export const zhTW: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} 現在使用 {model} 執行。omo.jsonc 不是 LazyCodex 可讀取格式的有效 JSON,因此未修改。", "integrations.lazycodexRoles.omoFailed": "{role} 現在使用 {model} 執行,但無法寫入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", + "integrations.lazycodexRoles.history.title": "變更紀錄", + "integrations.lazycodexRoles.history.hint": "本區段中的每次儲存與自動分配都會記錄在這裡。還原會像一般儲存一樣寫回先前的模型與推理強度,這次操作本身也會被記錄。原先沒有固定模型的角色保留目前模型,只還原推理強度。", + "integrations.lazycodexRoles.history.restore": "還原", + "integrations.lazycodexRoles.history.restored": "已將 {count} 個角色還原到 {time} 之前的狀態。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有註解,因此在這裡儲存只會更新角色檔案。請自行設定 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自動指派", "integrations.lazycodexRoles.auto.running": "正在評估角色…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 23075e443f1..f9561dbd851 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1922,6 +1922,10 @@ export const zh: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} 现在使用 {model} 运行。omo.jsonc 不是 LazyCodex 可读取格式的有效 JSON,因此未修改。", "integrations.lazycodexRoles.omoFailed": "{role} 现在使用 {model} 运行,但无法写入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", + "integrations.lazycodexRoles.history.title": "变更记录", + "integrations.lazycodexRoles.history.hint": "本节中的每次保存和自动分配都会记录在这里。恢复会像普通保存一样写回之前的模型和推理强度,这次操作本身也会被记录。原先没有固定模型的角色保留当前模型,只恢复推理强度。", + "integrations.lazycodexRoles.history.restore": "恢复", + "integrations.lazycodexRoles.history.restored": "已将 {count} 个角色恢复到 {time} 之前的状态。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有注释,因此在这里保存只会更新角色文件。请自行设置 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自动分配", "integrations.lazycodexRoles.auto.running": "正在评估角色…", diff --git a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx index 4b80c36a581..17b75e45592 100644 --- a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx @@ -36,7 +36,7 @@ export default function LazyCodexRoleAutoAssign({ }: { apiBase: string; busy: boolean; - apply: (role: string, model: string, effort: string | undefined) => Promise; + apply: (role: string, model: string, effort: string | undefined, batch?: string) => Promise; }) { const t = useT(); const [running, setRunning] = useState(false); @@ -70,8 +70,8 @@ export default function LazyCodexRoleAutoAssign({ const applicable = (result?.proposals ?? []).filter(p => p.status === "proposed" && p.proposedModel && !applied[p.role] && !alreadySet(p)); - const applyOne = async (proposal: RoleProposal): Promise => { - const ok = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? undefined); + const applyOne = async (proposal: RoleProposal, batch?: string): Promise => { + const ok = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? undefined, batch); if (ok) setApplied(current => ({ ...current, [proposal.role]: true })); return ok; }; @@ -80,8 +80,10 @@ export default function LazyCodexRoleAutoAssign({ setApplying(true); let count = 0; const total = applicable.length; + // One batch, so the role history restores the whole Apply all at once. + const batch = crypto.randomUUID(); // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror - for (const proposal of applicable) if (await applyOne(proposal)) count += 1; + for (const proposal of applicable) if (await applyOne(proposal, batch)) count += 1; setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); setApplying(false); }; diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx new file mode 100644 index 00000000000..3f1dbaf0121 --- /dev/null +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { useT } from "../../i18n/shared"; +import { Notice } from "../../ui"; + +export interface RoleSetting { + model: string | null; + effort: string | null; +} + +export interface RoleChange { + at: string; + batch: string; + role: string; + before: RoleSetting; + after: RoleSetting; +} + +type Restore = (role: string, model: string | undefined, effort: string | null, batch: string) => Promise; + +/** Newest first, as the server lists them; one entry per batch, so Apply all reads as one action. */ +function groupByBatch(history: RoleChange[]): RoleChange[][] { + const groups = new Map(); + for (const change of history) groups.set(change.batch, [...(groups.get(change.batch) ?? []), change]); + return [...groups.values()]; +} + +/** A role changed twice in one batch goes back to what it held before the first change. */ +function earliestBefore(group: RoleChange[]): Map { + const before = new Map(); + for (const change of group) before.set(change.role, change.before); + return before; +} + +export default function LazyCodexRoleHistory({ history, busy, restore }: { history: RoleChange[]; busy: boolean; restore: Restore }) { + const t = useT(); + const [restoring, setRestoring] = useState(null); + const [summary, setSummary] = useState(null); + if (history.length === 0) return null; + + const describe = (setting: RoleSetting) => { + const model = setting.model ?? t("integrations.lazycodexRoles.none"); + return setting.effort ? `${model} · ${setting.effort}` : model; + }; + + const restoreGroup = async (group: RoleChange[]) => { + setRestoring(group[0]!.batch); + setSummary(null); + const batch = crypto.randomUUID(); + let count = 0; + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror + for (const [role, setting] of earliestBefore(group)) { + // A role that had no model pin keeps its current one: a save can set a pin, not remove it. + if (await restore(role, setting.model ?? undefined, setting.effort, batch)) count += 1; + } + setSummary(t("integrations.lazycodexRoles.history.restored", { count: String(count), time: new Date(group[0]!.at).toLocaleString() })); + setRestoring(null); + }; + + return ( +
+
{t("integrations.lazycodexRoles.history.title")}
+

{t("integrations.lazycodexRoles.history.hint")}

+ {summary && {summary}} +
    + {groupByBatch(history).map(group => ( +
  • + {new Date(group[0]!.at).toLocaleString()} +
      + {group.map(change => ( +
    • + {change.role}: {describe(change.before)} → {describe(change.after)} +
    • + ))} +
    + +
  • + ))} +
+
+ ); +} diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx index 185010d4ec8..195779c0842 100644 --- a/gui/src/pages/integrations/LazyCodexRoleModels.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -5,6 +5,7 @@ import { readJsonOrThrow } from "../../fetch-json"; import { useT, type TKey } from "../../i18n/shared"; import { Notice, Select, type SelectOption } from "../../ui"; import LazyCodexRoleAutoAssign from "./LazyCodexRoleAutoAssign"; +import LazyCodexRoleHistory, { type RoleChange } from "./LazyCodexRoleHistory"; interface RoleRow { role: string; @@ -19,6 +20,7 @@ type OmoWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | interface RoleModels { detected: boolean; roles: RoleRow[]; + history: RoleChange[]; omoJsonc: { state: OmoFileState } | null; available: string[]; efforts: string[]; @@ -40,11 +42,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri const [result, setResult] = useState<{ tone: "ok" | "warn" | "err"; text: string } | null>(null); const load = useCallback(async (signal: AbortSignal): Promise => { - const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; omoJsonc?: { state: OmoFileState } | null }>( + const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; history?: RoleChange[]; omoJsonc?: { state: OmoFileState } | null }>( await fetch(`${apiBase}/api/codex-agent-roles`, { signal }), t("integrations.lazycodexRoles.loadFailed"), ); - if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], omoJsonc: null, available: [], efforts: [] }; + if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], history: [], omoJsonc: null, available: [], efforts: [] }; // The effort list is the one the Subagents page offers for its delegation model. It only adds // choices, so a failed lookup leaves each row with its pinned effort instead of failing the table. const [models, delegation] = await Promise.all([ @@ -57,6 +59,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri return { detected: true, roles: roles.roles ?? [], + history: roles.history ?? [], omoJsonc: roles.omoJsonc ?? null, available: models?.available ?? [], efforts: delegation?.efforts ?? [], @@ -74,7 +77,13 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri // An undefined model saves the effort alone and leaves the role's model pin, or its inheritance, // as it is. Auto-assign and model picks always pass a model. - const save = async (role: string, model: string | undefined, effort: string | null | undefined = undefined, displayedEffort?: string): Promise => { + const save = async ( + role: string, + model: string | undefined, + effort: string | null | undefined = undefined, + displayedEffort?: string, + batch?: string, + ): Promise => { if (pending !== null) return false; // An omitted effort still carries a pending omo.jsonc retry's effort, so a later save from any // path (the row or auto-assign) cannot drop it. An explicit effort replaces the retry. @@ -86,7 +95,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri const response = await fetch(`${apiBase}/api/codex-agent-roles/${encodeURIComponent(role)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...(model !== undefined ? { model } : {}), ...(sent !== undefined ? { effort: sent } : {}) }), + body: JSON.stringify({ + ...(model !== undefined ? { model } : {}), + ...(sent !== undefined ? { effort: sent } : {}), + ...(batch !== undefined ? { batch } : {}), + }), }); const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( response, @@ -167,7 +180,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri

{t("integrations.lazycodexRoles.empty")}

) : ( <> - + save(role, model, effort, undefined, batch)} + />
@@ -235,6 +252,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri
+ save(role, model, effort, undefined, batch)} + /> )} diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index 67ddbb12425..d2b83ac6568 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -1819,7 +1819,7 @@ test("the LazyCodex role section sits on the omo tab and only when LazyCodex is mountCount += 1; apiBase = `http://ocx-test-${mountCount}.invalid`; lazycodexRoles = { lazycodex: { detected: roles.detected }, omoJsonc: roles.omoJsonc, roles: roles.roles }; - setClientResourceData(`lazycodex-role-models:${apiBase}`, roles); + setClientResourceData(`lazycodex-role-models:${apiBase}`, { history: [], ...roles }); await act(async () => { root?.unmount(); root = createRoot(container); diff --git a/gui/tests/lazycodex-role-auto-assign.test.tsx b/gui/tests/lazycodex-role-auto-assign.test.tsx index 23f13af3b2c..efb500971ec 100644 --- a/gui/tests/lazycodex-role-auto-assign.test.tsx +++ b/gui/tests/lazycodex-role-auto-assign.test.tsx @@ -144,9 +144,11 @@ test("apply all writes each remaining proposal once and skips unsized roles", as await mount(); await click(button("Auto-assign")); await click(button("Apply all")); + const batch = (requests[1]!.body as { batch: string }).batch; + expect(batch).toMatch(/^[0-9a-f-]{36}$/); expect(requests.slice(1)).toEqual([ - { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low" } }, - { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid" } }, + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low", batch } }, + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid", batch } }, ]); expect(container.textContent).toContain("Applied 2 of 2 proposals."); expect(button("Apply all").disabled).toBe(true); @@ -206,7 +208,7 @@ test("Apply all merges both confirmed rows when every reload fails", async () => await click(button("Auto-assign")); reloadFails = true; await click(button("Apply all")); - expect(requests.slice(1).map(r => r.body)).toEqual([{ model: "a/small", effort: "low" }, { model: "a/mid" }]); + expect(requests.slice(1).map(r => r.body)).toEqual([{ model: "a/small", effort: "low", batch: expect.any(String) }, { model: "a/mid", batch: expect.any(String) }]); expect(roleRow("explorer").querySelectorAll("td")[1]!.textContent).toBe("a/small · low"); expect(roleRow("worker").querySelectorAll("td")[1]!.textContent).toBe("a/mid · high"); await expectEffortOptions("explorer", ["Inherit effort", "low", "medium", "high"]); diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index a4f454abe1e..cfd0734f8b7 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -535,3 +535,26 @@ test("picking a model after a failed effort-only mirror sends both, and only the expect(puts.map(entry => entry.body)).toEqual([{ effort: "low" }, { model: "xai/grok-4.5", effort: "low" }]); expect(current("librarian")).toBe("xai/grok-4.5 · low"); }); + +test("lists the role change history as one entry per batch and restores a batch's earlier values together", async () => { + rolesBody = { + ...(rolesBody as Record), + history: [ + { at: "2026-10-22T00:09:35.000Z", batch: "apply-all", role: "librarian", before: { model: null, effort: "medium" }, after: { model: "xai/grok-4.5", effort: "low" } }, + { at: "2026-10-22T00:09:34.000Z", batch: "apply-all", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "low" } }, + ], + }; + await mount(); + const entries = container.querySelectorAll(".lazycodex-role-history > ul > li"); + expect(entries.length).toBe(1); + expect(entries[0]!.textContent).toContain("explorer: gpt-5.5 → xai/grok-4.5 · low"); + const restore = entries[0]!.querySelector("button") as HTMLButtonElement; + await act(async () => { restore.click(); }); + await settle(); + expect(puts.map(put => ({ role: put.url.slice(put.url.lastIndexOf("/") + 1), body: put.body }))).toEqual([ + { role: "librarian", body: { effort: "medium", batch: (puts[0]!.body as { batch: string }).batch } }, + { role: "explorer", body: { model: "gpt-5.5", effort: null, batch: (puts[0]!.body as { batch: string }).batch } }, + ]); + expect((puts[0]!.body as { batch: string }).batch).not.toBe("apply-all"); + expect(container.textContent).toContain("Restored 2 role(s)"); +}); From d6de97d210f4aaf65a867afb53c7189247918822 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 03:08:14 +0900 Subject: [PATCH 03/16] fix(codex): list a role-model change batch whole, never cut by the 50-row window - listRoleModelChanges keeps every row of a batch the newest 50 reach, so Restore never writes back part of an Apply all, and the integrations guide names the history file under integrations/. --- docs-site/src/content/docs/guides/integrations.md | 2 +- src/codex/role-model-history.ts | 12 +++++++++--- tests/server/codex-agent-role-routes.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 5c834204b91..d1a03eeaaf0 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists every change a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `codex-agent-roles.jsonl` in the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. +Under the table, **Change history** lists every change a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. ## Kilo diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index e7f7e59e217..c5be9d04d48 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -59,12 +59,18 @@ function parseChange(line: string): RoleModelChange | null { : null; } -/** Newest first. A torn or foreign line is skipped, the way the integration journal reads its own. */ +/** + * Newest first, in whole batches: the newest `limit` changes plus the rest of any batch they cut into, + * so Restore never puts back part of an Apply all. A torn or foreign line is skipped, the way the + * integration journal reads its own. + */ export function listRoleModelChanges(limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { const path = historyPath(dir); if (!existsSync(path)) return []; - return readFileSync(path, "utf8").split("\n").flatMap(line => { + const newestFirst = readFileSync(path, "utf8").split("\n").flatMap(line => { const change = line.trim() ? parseChange(line) : null; return change ? [change] : []; - }).reverse().slice(0, limit); + }).reverse(); + const kept = new Set(newestFirst.slice(0, limit).map(change => change.batch)); + return newestFirst.filter((change, index) => index < limit || kept.has(change.batch)); } diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index 6852da0ab01..1f93b79b495 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -59,6 +59,17 @@ function put(role: string, model: unknown) { } describe("/api/codex-agent-roles", () => { + test("returns whole batches, so a batch cut by the newest 50 is listed with every role it wrote", async () => { + const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); + const dir = join(root, "history"); + const write = (batch: string, role: string) => recordRoleModelChange({ at: "2026-10-24T00:00:00.000Z", batch, role, before: { model: null, effort: null }, after: { model: "m", effort: null } }, dir); + for (let index = 0; index < 3; index++) write("older", `older-${index}`); + for (let index = 0; index < ROLE_HISTORY_LIMIT + 1; index++) write("apply-all", `role-${index}`); + const history = listRoleModelChanges(ROLE_HISTORY_LIMIT, dir); + expect(history.filter(change => change.batch === "apply-all")).toHaveLength(ROLE_HISTORY_LIMIT + 1); + expect(history.some(change => change.batch === "older")).toBe(false); + }); + test("records each written change with its earlier values, newest first, and skips an unchanged save", async () => { const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); expect((await write({ model: "xai/grok-4.5", effort: "high", batch: "apply-all-1" })).status).toBe(200); From 66e08435e759ab48571256e40e8d0bd6597a11c8 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 04:03:30 +0900 Subject: [PATCH 04/16] docs(codex): say the change history lists the newest 50 in whole batches - The integrations guide said every change; it now states the 50-row window and that a batch reaching into it is listed whole. --- docs-site/src/content/docs/guides/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index d1a03eeaaf0..99f35a6e7e9 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists every change a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. +Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. ## Kilo From 2177cfef288a6b31c82f89dcc27dd2dcb812d418 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 17:20:02 +0900 Subject: [PATCH 05/16] fix(codex): keep role editing when the change history cannot be read - GET /api/codex-agent-roles logs a history read failure and returns an empty list with historyUnavailable instead of failing the role list - the change history section shows a notice in its place, translated in every catalog - a route test makes the history path unreadable and checks the role list and a save still work --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + .../pages/integrations/LazyCodexRoleHistory.tsx | 3 ++- .../pages/integrations/LazyCodexRoleModels.tsx | 7 +++++-- gui/tests/lazycodex-role-models.test.tsx | 8 ++++++++ src/server/management/codex-agent-role-routes.ts | 12 +++++++++++- tests/server/codex-agent-role-routes.test.ts | 15 +++++++++++++++ 16 files changed, 52 insertions(+), 4 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 487a099c34f..a371a3892b4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1907,6 +1907,7 @@ export const de: Record = { "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", "integrations.lazycodexRoles.history.title": "Änderungsverlauf", "integrations.lazycodexRoles.history.hint": "Jedes Speichern und jede automatische Zuweisung in diesem Abschnitt wird hier aufgeführt. Wiederherstellen schreibt das frühere Modell und den früheren Aufwand wie ein normales Speichern zurück und wird ebenfalls aufgeführt. Eine Rolle ohne frühere Modellbindung behält ihr aktuelles Modell; nur der Aufwand wird zurückgesetzt.", + "integrations.lazycodexRoles.history.unavailable": "Der Änderungsverlauf konnte nicht gelesen werden, daher ist Wiederherstellen nicht verfügbar. Rollen lassen sich weiterhin bearbeiten.", "integrations.lazycodexRoles.history.restore": "Wiederherstellen", "integrations.lazycodexRoles.history.restored": "{count} Rolle(n) auf den Stand vor {time} zurückgesetzt.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Setze [codex].agents in omo.jsonc selbst.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 82d570f5ff2..d86aa2bef9d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2538,6 +2538,7 @@ export const en = { "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", "integrations.lazycodexRoles.history.title": "Change history", "integrations.lazycodexRoles.history.hint": "Each save and auto-assign in this section is listed here. Restore writes the earlier model and effort back as an ordinary save, so it is listed too. A role that had no model pin keeps its current one; only its effort goes back.", + "integrations.lazycodexRoles.history.unavailable": "The change history could not be read, so Restore is unavailable. Role editing still works.", "integrations.lazycodexRoles.history.restore": "Restore", "integrations.lazycodexRoles.history.restored": "Restored {count} role(s) to how they were before {time}.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc has comments, so saving here updates only the role files. Set [codex].agents in omo.jsonc yourself.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 7b330acc957..60d0deadc31 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2465,6 +2465,7 @@ export const fr: Record = { "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", "integrations.lazycodexRoles.history.title": "Historique des modifications", "integrations.lazycodexRoles.history.hint": "Chaque enregistrement et chaque attribution automatique de cette section figure ici. Restaurer réécrit le modèle et l'effort précédents comme un enregistrement ordinaire, qui figure donc aussi ici. Un rôle sans modèle épinglé auparavant garde son modèle actuel ; seul son effort est rétabli.", + "integrations.lazycodexRoles.history.unavailable": "L'historique des modifications n'a pas pu être lu, donc Restaurer n'est pas disponible. La modification des rôles fonctionne toujours.", "integrations.lazycodexRoles.history.restore": "Restaurer", "integrations.lazycodexRoles.history.restored": "{count} rôle(s) rétabli(s) à leur état d'avant {time}.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : enregistrer ici ne met à jour que les fichiers de rôle. Définissez [codex].agents dans omo.jsonc vous-même.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 82a2ea66533..74842294bd1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2360,6 +2360,7 @@ export const ja: Record = { "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", "integrations.lazycodexRoles.history.title": "変更履歴", "integrations.lazycodexRoles.history.hint": "このセクションでの保存と自動割り当てはすべてここに記録されます。元に戻すと以前のモデルと推論強度が通常の保存と同じ方法で書き戻され、その操作も記録されます。以前モデルが固定されていなかったロールは現在のモデルを保ち、推論強度だけを戻します。", + "integrations.lazycodexRoles.history.unavailable": "変更履歴を読み込めなかったため、元に戻す機能は使えません。ロールの編集はそのまま使えます。", "integrations.lazycodexRoles.history.restore": "元に戻す", "integrations.lazycodexRoles.history.restored": "{count} 件のロールを {time} より前の状態に戻しました。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが更新されます。omo.jsonc の [codex].agents はご自身で設定してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 095a74c9f30..82af4582175 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1951,6 +1951,7 @@ export const ko: Record = { "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", "integrations.lazycodexRoles.history.title": "변경 기록", "integrations.lazycodexRoles.history.hint": "이 섹션에서 저장하거나 자동 배정한 변경이 모두 여기에 남습니다. 되돌리기는 이전 모델과 추론 강도를 일반 저장과 같은 방식으로 다시 쓰고, 그 자체도 기록됩니다. 원래 모델이 지정되지 않았던 역할은 현재 모델을 유지하고 추론 강도만 되돌립니다.", + "integrations.lazycodexRoles.history.unavailable": "변경 기록을 읽지 못해 되돌리기를 쓸 수 없습니다. 역할 편집은 그대로 할 수 있습니다.", "integrations.lazycodexRoles.history.restore": "되돌리기", "integrations.lazycodexRoles.history.restored": "{count}개 역할을 {time} 이전 상태로 되돌렸습니다.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 [codex].agents는 직접 맞춰 주세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 4bb6818b781..79dbc82e0c5 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2509,6 +2509,7 @@ export const pt: Record = { "integrations.lazycodexRoles.retryMirror": "Tentar omo.jsonc novamente", "integrations.lazycodexRoles.history.title": "Histórico de alterações", "integrations.lazycodexRoles.history.hint": "Cada gravação e atribuição automática desta seção aparece aqui. Restaurar grava de volta o modelo e o esforço anteriores como uma gravação comum, que também aparece aqui. Um papel que não tinha modelo fixado mantém o atual; só o esforço volta.", + "integrations.lazycodexRoles.history.unavailable": "Não foi possível ler o histórico de alterações, por isso Restaurar não está disponível. A edição de papéis continua funcionando.", "integrations.lazycodexRoles.history.restore": "Restaurar", "integrations.lazycodexRoles.history.restored": "{count} papel(is) restaurado(s) ao estado anterior a {time}.", "integrations.lazycodexRoles.omoCommentsState": "O omo.jsonc tem comentários, então salvar aqui atualiza só os arquivos de papel. Defina [codex].agents no omo.jsonc você mesmo.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 10f5c15cd31..f0b0b00a82d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2451,6 +2451,7 @@ export const ru: Record = { "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", "integrations.lazycodexRoles.history.title": "История изменений", "integrations.lazycodexRoles.history.hint": "Здесь перечислены все сохранения и автоназначения в этом разделе. Восстановление записывает прежние модель и усилие как обычное сохранение и тоже попадает сюда. Роль, у которой раньше не было закреплённой модели, сохраняет текущую; возвращается только усилие.", + "integrations.lazycodexRoles.history.unavailable": "Не удалось прочитать историю изменений, поэтому восстановление недоступно. Редактирование ролей по-прежнему работает.", "integrations.lazycodexRoles.history.restore": "Восстановить", "integrations.lazycodexRoles.history.restored": "Восстановлено ролей: {count}, состояние до {time}.", "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь обновляет только файлы ролей. Задайте [codex].agents в omo.jsonc сами.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1d1adb35c73..372b7ad8d93 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2477,6 +2477,7 @@ export const tr: Record = { "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", "integrations.lazycodexRoles.history.title": "Değişiklik geçmişi", "integrations.lazycodexRoles.history.hint": "Bu bölümdeki her kaydetme ve otomatik atama burada listelenir. Geri yükleme önceki modeli ve çabayı sıradan bir kaydetme gibi geri yazar ve o da listelenir. Önceden sabitlenmiş modeli olmayan bir rol mevcut modelini korur; yalnızca çabası geri döner.", + "integrations.lazycodexRoles.history.unavailable": "Değişiklik geçmişi okunamadı, bu yüzden Geri yükleme kullanılamıyor. Rol düzenleme çalışmaya devam ediyor.", "integrations.lazycodexRoles.history.restore": "Geri yükle", "integrations.lazycodexRoles.history.restored": "{count} rol {time} öncesindeki durumuna geri yüklendi.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorumlar içeriyor, bu yüzden burada kaydetmek yalnızca rol dosyalarını günceller. omo.jsonc içindeki [codex].agents değerini kendiniz ayarlayın.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index eca1d6acbf3..a1ae202a500 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2486,6 +2486,7 @@ export const vi: Record = { "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", "integrations.lazycodexRoles.history.title": "Lịch sử thay đổi", "integrations.lazycodexRoles.history.hint": "Mọi lần lưu và tự động gán trong phần này đều được liệt kê ở đây. Khôi phục ghi lại mô hình và mức suy luận trước đó như một lần lưu thông thường, nên thao tác đó cũng được liệt kê. Vai trò trước đây không ghim mô hình sẽ giữ mô hình hiện tại; chỉ mức suy luận được khôi phục.", + "integrations.lazycodexRoles.history.unavailable": "Không đọc được lịch sử thay đổi nên không thể khôi phục. Bạn vẫn chỉnh sửa vai trò như bình thường.", "integrations.lazycodexRoles.history.restore": "Khôi phục", "integrations.lazycodexRoles.history.restored": "Đã khôi phục {count} vai trò về trạng thái trước {time}.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích, nên lưu ở đây chỉ cập nhật các tệp vai trò. Hãy tự đặt [codex].agents trong omo.jsonc.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 23d390e0a8e..a3c75c3e7f3 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3398,6 +3398,7 @@ export const zhTW: Record = { "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", "integrations.lazycodexRoles.history.title": "變更紀錄", "integrations.lazycodexRoles.history.hint": "本區段中的每次儲存與自動分配都會記錄在這裡。還原會像一般儲存一樣寫回先前的模型與推理強度,這次操作本身也會被記錄。原先沒有固定模型的角色保留目前模型,只還原推理強度。", + "integrations.lazycodexRoles.history.unavailable": "無法讀取變更記錄,因此無法還原。角色編輯仍可正常使用。", "integrations.lazycodexRoles.history.restore": "還原", "integrations.lazycodexRoles.history.restored": "已將 {count} 個角色還原到 {time} 之前的狀態。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有註解,因此在這裡儲存只會更新角色檔案。請自行設定 omo.jsonc 中的 [codex].agents。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f9561dbd851..c34ba9c33a6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1924,6 +1924,7 @@ export const zh: Record = { "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", "integrations.lazycodexRoles.history.title": "变更记录", "integrations.lazycodexRoles.history.hint": "本节中的每次保存和自动分配都会记录在这里。恢复会像普通保存一样写回之前的模型和推理强度,这次操作本身也会被记录。原先没有固定模型的角色保留当前模型,只恢复推理强度。", + "integrations.lazycodexRoles.history.unavailable": "无法读取变更记录,因此无法恢复。角色编辑仍可正常使用。", "integrations.lazycodexRoles.history.restore": "恢复", "integrations.lazycodexRoles.history.restored": "已将 {count} 个角色恢复到 {time} 之前的状态。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有注释,因此在这里保存只会更新角色文件。请自行设置 omo.jsonc 中的 [codex].agents。", diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx index 3f1dbaf0121..828ff5d24a7 100644 --- a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -31,10 +31,11 @@ function earliestBefore(group: RoleChange[]): Map { return before; } -export default function LazyCodexRoleHistory({ history, busy, restore }: { history: RoleChange[]; busy: boolean; restore: Restore }) { +export default function LazyCodexRoleHistory({ history, unavailable, busy, restore }: { history: RoleChange[]; unavailable: boolean; busy: boolean; restore: Restore }) { const t = useT(); const [restoring, setRestoring] = useState(null); const [summary, setSummary] = useState(null); + if (unavailable) return {t("integrations.lazycodexRoles.history.unavailable")}; if (history.length === 0) return null; const describe = (setting: RoleSetting) => { diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx index 195779c0842..b55f2667eb1 100644 --- a/gui/src/pages/integrations/LazyCodexRoleModels.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -21,6 +21,7 @@ interface RoleModels { detected: boolean; roles: RoleRow[]; history: RoleChange[]; + historyUnavailable: boolean; omoJsonc: { state: OmoFileState } | null; available: string[]; efforts: string[]; @@ -42,11 +43,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri const [result, setResult] = useState<{ tone: "ok" | "warn" | "err"; text: string } | null>(null); const load = useCallback(async (signal: AbortSignal): Promise => { - const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; history?: RoleChange[]; omoJsonc?: { state: OmoFileState } | null }>( + const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; history?: RoleChange[]; historyUnavailable?: boolean; omoJsonc?: { state: OmoFileState } | null }>( await fetch(`${apiBase}/api/codex-agent-roles`, { signal }), t("integrations.lazycodexRoles.loadFailed"), ); - if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], history: [], omoJsonc: null, available: [], efforts: [] }; + if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], history: [], historyUnavailable: false, omoJsonc: null, available: [], efforts: [] }; // The effort list is the one the Subagents page offers for its delegation model. It only adds // choices, so a failed lookup leaves each row with its pinned effort instead of failing the table. const [models, delegation] = await Promise.all([ @@ -60,6 +61,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri detected: true, roles: roles.roles ?? [], history: roles.history ?? [], + historyUnavailable: roles.historyUnavailable === true, omoJsonc: roles.omoJsonc ?? null, available: models?.available ?? [], efforts: delegation?.efforts ?? [], @@ -254,6 +256,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri save(role, model, effort, undefined, batch)} /> diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index cfd0734f8b7..2f880d347e6 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -558,3 +558,11 @@ test("lists the role change history as one entry per batch and restores a batch' expect((puts[0]!.body as { batch: string }).batch).not.toBe("apply-all"); expect(container.textContent).toContain("Restored 2 role(s)"); }); + +test("keeps the role table editable and says why Restore is gone when the history cannot be read", async () => { + rolesBody = { ...(rolesBody as Record), history: [], historyUnavailable: true }; + await mount(); + expect(container.textContent).toContain("The change history could not be read"); + expect(container.querySelector(".lazycodex-role-history")).toBeNull(); + expect(saveButton("explorer")).not.toBeNull(); +}); diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index 34867ab0d68..1c7aef81825 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -36,6 +36,15 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (!lazycodex.detected) return jsonResponse({ lazycodex, omoJsonc: null, roles: [] }, 200, req, config); const omoState = omo.readOmoRoleModels(omo.omoJsoncPath()); const omoModels = omoState.state === "present" ? omoState.models : {}; + // History is optional: a file that cannot be read costs the Restore list, never role editing. + let changes: import("../../codex/role-model-history").RoleModelChange[] = []; + let historyUnavailable = false; + try { + changes = history.listRoleModelChanges(); + } catch (error) { + console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); + historyUnavailable = true; + } return jsonResponse({ lazycodex, omoJsonc: { state: omoState.state }, @@ -44,7 +53,8 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis effort: roles.readCodexAgentRoleEffort(entry.role, codexHome), omoJsoncModel: omoModels[entry.role] ?? null, })), - history: history.listRoleModelChanges(), + history: changes, + ...(historyUnavailable ? { historyUnavailable } : {}), }, 200, req, config); } diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index 1f93b79b495..73550818a39 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -70,6 +70,21 @@ describe("/api/codex-agent-roles", () => { expect(history.some(change => change.batch === "older")).toBe(false); }); + test("an unreadable history file leaves the role list and role saves working", async () => { + // A directory where the history file belongs makes every read of it fail (EISDIR) on every platform. + mkdirSync(join(root, "ocx", "integrations", "codex-agent-roles.jsonl"), { recursive: true }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + const listed = await call("/api/codex-agent-roles"); + expect(listed.status).toBe(200); + expect(listed.body).toMatchObject({ roles: [{ role: "explorer", model: "gpt-5.5" }], history: [], historyUnavailable: true }); + expect((await put("explorer", "xai/grok-4.5")).status).toBe(200); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toContain('model = "xai/grok-4.5"'); + } finally { + error.mockRestore(); + } + }); + test("records each written change with its earlier values, newest first, and skips an unchanged save", async () => { const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); expect((await write({ model: "xai/grok-4.5", effort: "high", batch: "apply-all-1" })).status).toBe(200); From 428b61a786791073e3742c2f3acd1acf4234c968 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 17:20:02 +0900 Subject: [PATCH 06/16] docs(codex): say the role history widens to whole batches and can be unavailable --- docs-site/src/content/docs/guides/integrations.md | 2 +- structure/subagents.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 99f35a6e7e9..c300b5503ef 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. +Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. If that file cannot be read, the section says so in place of the list, and the role table keeps working. ## Kilo diff --git a/structure/subagents.md b/structure/subagents.md index e6e174ce0ed..04d85bb5dce 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,7 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. -- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, and model and effort before and after. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. `GET /api/codex-agent-roles` returns the newest 50 rows as `history` when LazyCodex is detected. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, and model and effort before and after. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. `GET /api/codex-agent-roles` returns the newest 50 rows as `history`, widened to the whole of any batch those rows cut into, when LazyCodex is detected; a history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string From 5c2279cc611509251d0437a78eaf7adf1a9c6edb Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 19:31:49 +0900 Subject: [PATCH 07/16] fix(gui): stop a role Restore at the first failed write and say so - Restore stops at the first role whose save fails and shows a warning with the role and how many of the batch went back, instead of a success notice - a GUI test fails the first save and checks one write and the warning --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + .../integrations/LazyCodexRoleHistory.tsx | 15 ++++++++++----- gui/tests/lazycodex-role-models.test.tsx | 18 ++++++++++++++++++ 13 files changed, 39 insertions(+), 5 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a371a3892b4..28b53290229 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1910,6 +1910,7 @@ export const de: Record = { "integrations.lazycodexRoles.history.unavailable": "Der Änderungsverlauf konnte nicht gelesen werden, daher ist Wiederherstellen nicht verfügbar. Rollen lassen sich weiterhin bearbeiten.", "integrations.lazycodexRoles.history.restore": "Wiederherstellen", "integrations.lazycodexRoles.history.restored": "{count} Rolle(n) auf den Stand vor {time} zurückgesetzt.", + "integrations.lazycodexRoles.history.partial": "Wiederherstellen bei {role} abgebrochen: {count} von {total} Rolle(n) wurden zurückgesetzt, die übrigen behalten ihre aktuellen Werte. Behebe den Fehler oben und stelle erneut wieder her.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Setze [codex].agents in omo.jsonc selbst.", "integrations.lazycodexRoles.auto.button": "Automatisch zuweisen", "integrations.lazycodexRoles.auto.running": "Rollen werden eingestuft…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d86aa2bef9d..6184a677d3a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2541,6 +2541,7 @@ export const en = { "integrations.lazycodexRoles.history.unavailable": "The change history could not be read, so Restore is unavailable. Role editing still works.", "integrations.lazycodexRoles.history.restore": "Restore", "integrations.lazycodexRoles.history.restored": "Restored {count} role(s) to how they were before {time}.", + "integrations.lazycodexRoles.history.partial": "Restore stopped at {role}: {count} of {total} role(s) went back, and the rest keep their current values. Fix the error above and restore again.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc has comments, so saving here updates only the role files. Set [codex].agents in omo.jsonc yourself.", "integrations.lazycodexRoles.auto.button": "Auto-assign", "integrations.lazycodexRoles.auto.running": "Sizing roles…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 60d0deadc31..e74df3a74c8 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2468,6 +2468,7 @@ export const fr: Record = { "integrations.lazycodexRoles.history.unavailable": "L'historique des modifications n'a pas pu être lu, donc Restaurer n'est pas disponible. La modification des rôles fonctionne toujours.", "integrations.lazycodexRoles.history.restore": "Restaurer", "integrations.lazycodexRoles.history.restored": "{count} rôle(s) rétabli(s) à leur état d'avant {time}.", + "integrations.lazycodexRoles.history.partial": "La restauration s'est arrêtée à {role} : {count} rôle(s) sur {total} ont été rétablis, les autres gardent leurs valeurs actuelles. Corrigez l'erreur ci-dessus et restaurez à nouveau.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : enregistrer ici ne met à jour que les fichiers de rôle. Définissez [codex].agents dans omo.jsonc vous-même.", "integrations.lazycodexRoles.auto.button": "Attribution auto", "integrations.lazycodexRoles.auto.running": "Évaluation des rôles…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 74842294bd1..693901aa7f6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2363,6 +2363,7 @@ export const ja: Record = { "integrations.lazycodexRoles.history.unavailable": "変更履歴を読み込めなかったため、元に戻す機能は使えません。ロールの編集はそのまま使えます。", "integrations.lazycodexRoles.history.restore": "元に戻す", "integrations.lazycodexRoles.history.restored": "{count} 件のロールを {time} より前の状態に戻しました。", + "integrations.lazycodexRoles.history.partial": "{role} で元に戻す処理が止まりました。{total} 件中 {count} 件のロールが戻り、残りは現在の値のままです。上のエラーを解消してからもう一度元に戻してください。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが更新されます。omo.jsonc の [codex].agents はご自身で設定してください。", "integrations.lazycodexRoles.auto.button": "自動割り当て", "integrations.lazycodexRoles.auto.running": "ロールを評価中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 82af4582175..b6423015ccc 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1954,6 +1954,7 @@ export const ko: Record = { "integrations.lazycodexRoles.history.unavailable": "변경 기록을 읽지 못해 되돌리기를 쓸 수 없습니다. 역할 편집은 그대로 할 수 있습니다.", "integrations.lazycodexRoles.history.restore": "되돌리기", "integrations.lazycodexRoles.history.restored": "{count}개 역할을 {time} 이전 상태로 되돌렸습니다.", + "integrations.lazycodexRoles.history.partial": "{role}에서 되돌리기가 멈췄습니다. {total}개 중 {count}개 역할만 되돌아갔고 나머지는 현재 값을 유지합니다. 위의 오류를 해결한 뒤 다시 되돌리세요.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 [codex].agents는 직접 맞춰 주세요.", "integrations.lazycodexRoles.auto.button": "자동 배정", "integrations.lazycodexRoles.auto.running": "역할 평가 중…", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 79dbc82e0c5..10571d3736f 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2512,6 +2512,7 @@ export const pt: Record = { "integrations.lazycodexRoles.history.unavailable": "Não foi possível ler o histórico de alterações, por isso Restaurar não está disponível. A edição de papéis continua funcionando.", "integrations.lazycodexRoles.history.restore": "Restaurar", "integrations.lazycodexRoles.history.restored": "{count} papel(is) restaurado(s) ao estado anterior a {time}.", + "integrations.lazycodexRoles.history.partial": "A restauração parou em {role}: {count} de {total} papel(is) voltaram, e os demais mantêm os valores atuais. Corrija o erro acima e restaure novamente.", "integrations.lazycodexRoles.omoCommentsState": "O omo.jsonc tem comentários, então salvar aqui atualiza só os arquivos de papel. Defina [codex].agents no omo.jsonc você mesmo.", "integrations.lazycodexRoles.auto.button": "Atribuir automaticamente", "integrations.lazycodexRoles.auto.running": "Dimensionando funções…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f0b0b00a82d..8944e13e07c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2454,6 +2454,7 @@ export const ru: Record = { "integrations.lazycodexRoles.history.unavailable": "Не удалось прочитать историю изменений, поэтому восстановление недоступно. Редактирование ролей по-прежнему работает.", "integrations.lazycodexRoles.history.restore": "Восстановить", "integrations.lazycodexRoles.history.restored": "Восстановлено ролей: {count}, состояние до {time}.", + "integrations.lazycodexRoles.history.partial": "Восстановление остановилось на {role}: возвращено ролей {count} из {total}, остальные сохраняют текущие значения. Исправьте ошибку выше и восстановите снова.", "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь обновляет только файлы ролей. Задайте [codex].agents в omo.jsonc сами.", "integrations.lazycodexRoles.auto.button": "Автоназначение", "integrations.lazycodexRoles.auto.running": "Оценка ролей…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 372b7ad8d93..88f7a72a1d7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2480,6 +2480,7 @@ export const tr: Record = { "integrations.lazycodexRoles.history.unavailable": "Değişiklik geçmişi okunamadı, bu yüzden Geri yükleme kullanılamıyor. Rol düzenleme çalışmaya devam ediyor.", "integrations.lazycodexRoles.history.restore": "Geri yükle", "integrations.lazycodexRoles.history.restored": "{count} rol {time} öncesindeki durumuna geri yüklendi.", + "integrations.lazycodexRoles.history.partial": "Geri yükleme {role} rolünde durdu: {total} rolden {count} tanesi geri döndü, diğerleri mevcut değerlerini koruyor. Yukarıdaki hatayı düzeltip yeniden geri yükleyin.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorumlar içeriyor, bu yüzden burada kaydetmek yalnızca rol dosyalarını günceller. omo.jsonc içindeki [codex].agents değerini kendiniz ayarlayın.", "integrations.lazycodexRoles.auto.button": "Otomatik ata", "integrations.lazycodexRoles.auto.running": "Roller değerlendiriliyor…", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a1ae202a500..21dc08be2e2 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2489,6 +2489,7 @@ export const vi: Record = { "integrations.lazycodexRoles.history.unavailable": "Không đọc được lịch sử thay đổi nên không thể khôi phục. Bạn vẫn chỉnh sửa vai trò như bình thường.", "integrations.lazycodexRoles.history.restore": "Khôi phục", "integrations.lazycodexRoles.history.restored": "Đã khôi phục {count} vai trò về trạng thái trước {time}.", + "integrations.lazycodexRoles.history.partial": "Khôi phục dừng ở {role}: {count}/{total} vai trò đã được khôi phục, các vai trò còn lại giữ giá trị hiện tại. Hãy sửa lỗi ở trên rồi khôi phục lại.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích, nên lưu ở đây chỉ cập nhật các tệp vai trò. Hãy tự đặt [codex].agents trong omo.jsonc.", "integrations.lazycodexRoles.auto.button": "Tự động gán", "integrations.lazycodexRoles.auto.running": "Đang đánh giá vai trò…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a3c75c3e7f3..e8f4f8539a3 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3401,6 +3401,7 @@ export const zhTW: Record = { "integrations.lazycodexRoles.history.unavailable": "無法讀取變更記錄,因此無法還原。角色編輯仍可正常使用。", "integrations.lazycodexRoles.history.restore": "還原", "integrations.lazycodexRoles.history.restored": "已將 {count} 個角色還原到 {time} 之前的狀態。", + "integrations.lazycodexRoles.history.partial": "還原在 {role} 停止:{total} 個角色中已還原 {count} 個,其餘保留目前的值。請先修正上方的錯誤,再重新還原。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有註解,因此在這裡儲存只會更新角色檔案。請自行設定 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自動指派", "integrations.lazycodexRoles.auto.running": "正在評估角色…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c34ba9c33a6..7c899e49ef4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1927,6 +1927,7 @@ export const zh: Record = { "integrations.lazycodexRoles.history.unavailable": "无法读取变更记录,因此无法恢复。角色编辑仍可正常使用。", "integrations.lazycodexRoles.history.restore": "恢复", "integrations.lazycodexRoles.history.restored": "已将 {count} 个角色恢复到 {time} 之前的状态。", + "integrations.lazycodexRoles.history.partial": "恢复在 {role} 停止:{total} 个角色中已恢复 {count} 个,其余保留当前的值。请先修复上方的错误,再重新恢复。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有注释,因此在这里保存只会更新角色文件。请自行设置 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自动分配", "integrations.lazycodexRoles.auto.running": "正在评估角色…", diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx index 828ff5d24a7..9c656f9d41e 100644 --- a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -34,7 +34,7 @@ function earliestBefore(group: RoleChange[]): Map { export default function LazyCodexRoleHistory({ history, unavailable, busy, restore }: { history: RoleChange[]; unavailable: boolean; busy: boolean; restore: Restore }) { const t = useT(); const [restoring, setRestoring] = useState(null); - const [summary, setSummary] = useState(null); + const [summary, setSummary] = useState<{ tone: "ok" | "warn"; text: string } | null>(null); if (unavailable) return {t("integrations.lazycodexRoles.history.unavailable")}; if (history.length === 0) return null; @@ -47,13 +47,18 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto setRestoring(group[0]!.batch); setSummary(null); const batch = crypto.randomUUID(); + const roles = [...earliestBefore(group)]; let count = 0; // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror - for (const [role, setting] of earliestBefore(group)) { + for (const [role, setting] of roles) { // A role that had no model pin keeps its current one: a save can set a pin, not remove it. - if (await restore(role, setting.model ?? undefined, setting.effort, batch)) count += 1; + if (!(await restore(role, setting.model ?? undefined, setting.effort, batch))) break; + count += 1; } - setSummary(t("integrations.lazycodexRoles.history.restored", { count: String(count), time: new Date(group[0]!.at).toLocaleString() })); + const time = new Date(group[0]!.at).toLocaleString(); + setSummary(count === roles.length + ? { tone: "ok", text: t("integrations.lazycodexRoles.history.restored", { count: String(count), time }) } + : { tone: "warn", text: t("integrations.lazycodexRoles.history.partial", { count: String(count), total: String(roles.length), role: roles[count]![0] }) }); setRestoring(null); }; @@ -61,7 +66,7 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto
{t("integrations.lazycodexRoles.history.title")}

{t("integrations.lazycodexRoles.history.hint")}

- {summary && {summary}} + {summary && {summary.text}}
    {groupByBatch(history).map(group => (
  • diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index 2f880d347e6..fe4a694c4b0 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -566,3 +566,21 @@ test("keeps the role table editable and says why Restore is gone when the histor expect(container.querySelector(".lazycodex-role-history")).toBeNull(); expect(saveButton("explorer")).not.toBeNull(); }); + +test("stops a Restore at the first failed role and warns that the batch is only partly back", async () => { + rolesBody = { + ...(rolesBody as Record), + history: [ + { at: "2026-10-22T00:09:35.000Z", batch: "apply-all", role: "librarian", before: { model: null, effort: "medium" }, after: { model: "xai/grok-4.5", effort: "low" } }, + { at: "2026-10-22T00:09:34.000Z", batch: "apply-all", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "low" } }, + ], + }; + await mount(); + putFailure = { status: 500, body: { error: "write failed" } }; + const restore = container.querySelector(".lazycodex-role-history > ul > li button") as HTMLButtonElement; + await act(async () => { restore.click(); }); + await settle(); + expect(puts).toHaveLength(1); + expect(container.textContent).toContain("Restore stopped at librarian: 0 of 2 role(s) went back"); + expect(container.textContent).not.toContain("Restored 0 role(s)"); +}); From f3071ee8b66daea57fe79e8ddb7f42632c0d8b0b Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 19:31:49 +0900 Subject: [PATCH 08/16] docs(codex): name the history file in the read-failure sentence --- docs-site/src/content/docs/guides/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index c300b5503ef..521ece7ca49 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. If that file cannot be read, the section says so in place of the list, and the role table keeps working. +Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. ## Kilo From c3f41be1c690329951a83112e81fda0e5604ed16 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 25 Oct 2026 20:07:48 +0900 Subject: [PATCH 09/16] fix(gui): date the role history in the dashboard's language and state its limit - history entry dates and the restore notice format with the selected dashboard locale instead of the browser default - the history hint says it lists the newest 50 changes in whole batches, in every catalog, and the Chinese partial-restore wording reads naturally --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/pt.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 4 ++-- gui/src/i18n/zh.ts | 4 ++-- gui/src/pages/integrations/LazyCodexRoleHistory.tsx | 8 ++++---- 12 files changed, 17 insertions(+), 17 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 28b53290229..e9484f5a019 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1906,7 +1906,7 @@ export const de: Record = { "integrations.lazycodexRoles.omoFailed": "{role} läuft jetzt mit {model}, aber omo.jsonc konnte nicht geschrieben werden.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", "integrations.lazycodexRoles.history.title": "Änderungsverlauf", - "integrations.lazycodexRoles.history.hint": "Jedes Speichern und jede automatische Zuweisung in diesem Abschnitt wird hier aufgeführt. Wiederherstellen schreibt das frühere Modell und den früheren Aufwand wie ein normales Speichern zurück und wird ebenfalls aufgeführt. Eine Rolle ohne frühere Modellbindung behält ihr aktuelles Modell; nur der Aufwand wird zurückgesetzt.", + "integrations.lazycodexRoles.history.hint": "Hier stehen die letzten 50 Speichervorgänge und automatischen Zuweisungen dieses Abschnitts, jeweils als ganze Aktion. Wiederherstellen schreibt das frühere Modell und den früheren Aufwand wie ein normales Speichern zurück und wird ebenfalls aufgeführt. Eine Rolle ohne frühere Modellbindung behält ihr aktuelles Modell; nur der Aufwand wird zurückgesetzt.", "integrations.lazycodexRoles.history.unavailable": "Der Änderungsverlauf konnte nicht gelesen werden, daher ist Wiederherstellen nicht verfügbar. Rollen lassen sich weiterhin bearbeiten.", "integrations.lazycodexRoles.history.restore": "Wiederherstellen", "integrations.lazycodexRoles.history.restored": "{count} Rolle(n) auf den Stand vor {time} zurückgesetzt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6184a677d3a..5d0d22253f8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2537,7 +2537,7 @@ export const en = { "integrations.lazycodexRoles.omoFailed": "{role} now runs on {model}, but omo.jsonc could not be written.", "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", "integrations.lazycodexRoles.history.title": "Change history", - "integrations.lazycodexRoles.history.hint": "Each save and auto-assign in this section is listed here. Restore writes the earlier model and effort back as an ordinary save, so it is listed too. A role that had no model pin keeps its current one; only its effort goes back.", + "integrations.lazycodexRoles.history.hint": "The newest 50 saves and auto-assigns in this section are listed here, kept in whole batches. Restore writes the earlier model and effort back as an ordinary save, so it is listed too. A role that had no model pin keeps its current one; only its effort goes back.", "integrations.lazycodexRoles.history.unavailable": "The change history could not be read, so Restore is unavailable. Role editing still works.", "integrations.lazycodexRoles.history.restore": "Restore", "integrations.lazycodexRoles.history.restored": "Restored {count} role(s) to how they were before {time}.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e74df3a74c8..847a71db52a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2464,7 +2464,7 @@ export const fr: Record = { "integrations.lazycodexRoles.omoFailed": "{role} utilise désormais {model}, mais omo.jsonc n'a pas pu être écrit.", "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", "integrations.lazycodexRoles.history.title": "Historique des modifications", - "integrations.lazycodexRoles.history.hint": "Chaque enregistrement et chaque attribution automatique de cette section figure ici. Restaurer réécrit le modèle et l'effort précédents comme un enregistrement ordinaire, qui figure donc aussi ici. Un rôle sans modèle épinglé auparavant garde son modèle actuel ; seul son effort est rétabli.", + "integrations.lazycodexRoles.history.hint": "Les 50 derniers enregistrements et attributions automatiques de cette section figurent ici, regroupés par action entière. Restaurer réécrit le modèle et l'effort précédents comme un enregistrement ordinaire, qui figure donc aussi ici. Un rôle sans modèle épinglé auparavant garde son modèle actuel ; seul son effort est rétabli.", "integrations.lazycodexRoles.history.unavailable": "L'historique des modifications n'a pas pu être lu, donc Restaurer n'est pas disponible. La modification des rôles fonctionne toujours.", "integrations.lazycodexRoles.history.restore": "Restaurer", "integrations.lazycodexRoles.history.restored": "{count} rôle(s) rétabli(s) à leur état d'avant {time}.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 693901aa7f6..65ff55a8905 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2359,7 +2359,7 @@ export const ja: Record = { "integrations.lazycodexRoles.omoFailed": "{role} は {model} で動くようになりましたが、omo.jsonc は書き込めませんでした。", "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", "integrations.lazycodexRoles.history.title": "変更履歴", - "integrations.lazycodexRoles.history.hint": "このセクションでの保存と自動割り当てはすべてここに記録されます。元に戻すと以前のモデルと推論強度が通常の保存と同じ方法で書き戻され、その操作も記録されます。以前モデルが固定されていなかったロールは現在のモデルを保ち、推論強度だけを戻します。", + "integrations.lazycodexRoles.history.hint": "このセクションでの保存と自動割り当てのうち、新しい 50 件が操作単位でここに記録されます。元に戻すと以前のモデルと推論強度が通常の保存と同じ方法で書き戻され、その操作も記録されます。以前モデルが固定されていなかったロールは現在のモデルを保ち、推論強度だけを戻します。", "integrations.lazycodexRoles.history.unavailable": "変更履歴を読み込めなかったため、元に戻す機能は使えません。ロールの編集はそのまま使えます。", "integrations.lazycodexRoles.history.restore": "元に戻す", "integrations.lazycodexRoles.history.restored": "{count} 件のロールを {time} より前の状態に戻しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b6423015ccc..c1d5bdc467e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1950,7 +1950,7 @@ export const ko: Record = { "integrations.lazycodexRoles.omoFailed": "{role}은(는) 이제 {model}로 실행되지만, omo.jsonc는 쓰지 못했습니다.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", "integrations.lazycodexRoles.history.title": "변경 기록", - "integrations.lazycodexRoles.history.hint": "이 섹션에서 저장하거나 자동 배정한 변경이 모두 여기에 남습니다. 되돌리기는 이전 모델과 추론 강도를 일반 저장과 같은 방식으로 다시 쓰고, 그 자체도 기록됩니다. 원래 모델이 지정되지 않았던 역할은 현재 모델을 유지하고 추론 강도만 되돌립니다.", + "integrations.lazycodexRoles.history.hint": "이 섹션에서 저장하거나 자동 배정한 변경 중 최근 50개가 작업 단위로 여기에 남습니다. 되돌리기는 이전 모델과 추론 강도를 일반 저장과 같은 방식으로 다시 쓰고, 그 자체도 기록됩니다. 원래 모델이 지정되지 않았던 역할은 현재 모델을 유지하고 추론 강도만 되돌립니다.", "integrations.lazycodexRoles.history.unavailable": "변경 기록을 읽지 못해 되돌리기를 쓸 수 없습니다. 역할 편집은 그대로 할 수 있습니다.", "integrations.lazycodexRoles.history.restore": "되돌리기", "integrations.lazycodexRoles.history.restored": "{count}개 역할을 {time} 이전 상태로 되돌렸습니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 10571d3736f..a497da49856 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2508,7 +2508,7 @@ export const pt: Record = { "integrations.lazycodexRoles.omoFailed": "{role} agora usa {model}, mas não foi possível gravar o omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Tentar omo.jsonc novamente", "integrations.lazycodexRoles.history.title": "Histórico de alterações", - "integrations.lazycodexRoles.history.hint": "Cada gravação e atribuição automática desta seção aparece aqui. Restaurar grava de volta o modelo e o esforço anteriores como uma gravação comum, que também aparece aqui. Um papel que não tinha modelo fixado mantém o atual; só o esforço volta.", + "integrations.lazycodexRoles.history.hint": "As 50 gravações e atribuições automáticas mais recentes desta seção aparecem aqui, agrupadas por ação inteira. Restaurar grava de volta o modelo e o esforço anteriores como uma gravação comum, que também aparece aqui. Um papel que não tinha modelo fixado mantém o atual; só o esforço volta.", "integrations.lazycodexRoles.history.unavailable": "Não foi possível ler o histórico de alterações, por isso Restaurar não está disponível. A edição de papéis continua funcionando.", "integrations.lazycodexRoles.history.restore": "Restaurar", "integrations.lazycodexRoles.history.restored": "{count} papel(is) restaurado(s) ao estado anterior a {time}.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8944e13e07c..49ad2ab7931 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2450,7 +2450,7 @@ export const ru: Record = { "integrations.lazycodexRoles.omoFailed": "{role} теперь работает на {model}, но записать omo.jsonc не удалось.", "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", "integrations.lazycodexRoles.history.title": "История изменений", - "integrations.lazycodexRoles.history.hint": "Здесь перечислены все сохранения и автоназначения в этом разделе. Восстановление записывает прежние модель и усилие как обычное сохранение и тоже попадает сюда. Роль, у которой раньше не было закреплённой модели, сохраняет текущую; возвращается только усилие.", + "integrations.lazycodexRoles.history.hint": "Здесь перечислены последние 50 сохранений и автоназначений в этом разделе, целыми действиями. Восстановление записывает прежние модель и усилие как обычное сохранение и тоже попадает сюда. Роль, у которой раньше не было закреплённой модели, сохраняет текущую; возвращается только усилие.", "integrations.lazycodexRoles.history.unavailable": "Не удалось прочитать историю изменений, поэтому восстановление недоступно. Редактирование ролей по-прежнему работает.", "integrations.lazycodexRoles.history.restore": "Восстановить", "integrations.lazycodexRoles.history.restored": "Восстановлено ролей: {count}, состояние до {time}.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 88f7a72a1d7..c05c5b8eb1c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2476,7 +2476,7 @@ export const tr: Record = { "integrations.lazycodexRoles.omoFailed": "{role} artık {model} ile çalışıyor, ancak omo.jsonc yazılamadı.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", "integrations.lazycodexRoles.history.title": "Değişiklik geçmişi", - "integrations.lazycodexRoles.history.hint": "Bu bölümdeki her kaydetme ve otomatik atama burada listelenir. Geri yükleme önceki modeli ve çabayı sıradan bir kaydetme gibi geri yazar ve o da listelenir. Önceden sabitlenmiş modeli olmayan bir rol mevcut modelini korur; yalnızca çabası geri döner.", + "integrations.lazycodexRoles.history.hint": "Bu bölümdeki en yeni 50 kaydetme ve otomatik atama, bütün işlemler halinde burada listelenir. Geri yükleme önceki modeli ve çabayı sıradan bir kaydetme gibi geri yazar ve o da listelenir. Önceden sabitlenmiş modeli olmayan bir rol mevcut modelini korur; yalnızca çabası geri döner.", "integrations.lazycodexRoles.history.unavailable": "Değişiklik geçmişi okunamadı, bu yüzden Geri yükleme kullanılamıyor. Rol düzenleme çalışmaya devam ediyor.", "integrations.lazycodexRoles.history.restore": "Geri yükle", "integrations.lazycodexRoles.history.restored": "{count} rol {time} öncesindeki durumuna geri yüklendi.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 21dc08be2e2..80ffac1e14e 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2485,7 +2485,7 @@ export const vi: Record = { "integrations.lazycodexRoles.omoFailed": "{role} giờ chạy bằng {model}, nhưng không ghi được omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", "integrations.lazycodexRoles.history.title": "Lịch sử thay đổi", - "integrations.lazycodexRoles.history.hint": "Mọi lần lưu và tự động gán trong phần này đều được liệt kê ở đây. Khôi phục ghi lại mô hình và mức suy luận trước đó như một lần lưu thông thường, nên thao tác đó cũng được liệt kê. Vai trò trước đây không ghim mô hình sẽ giữ mô hình hiện tại; chỉ mức suy luận được khôi phục.", + "integrations.lazycodexRoles.history.hint": "50 lần lưu và tự động gán gần nhất trong phần này được liệt kê ở đây, theo từng thao tác trọn vẹn. Khôi phục ghi lại mô hình và mức suy luận trước đó như một lần lưu thông thường, nên thao tác đó cũng được liệt kê. Vai trò trước đây không ghim mô hình sẽ giữ mô hình hiện tại; chỉ mức suy luận được khôi phục.", "integrations.lazycodexRoles.history.unavailable": "Không đọc được lịch sử thay đổi nên không thể khôi phục. Bạn vẫn chỉnh sửa vai trò như bình thường.", "integrations.lazycodexRoles.history.restore": "Khôi phục", "integrations.lazycodexRoles.history.restored": "Đã khôi phục {count} vai trò về trạng thái trước {time}.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e8f4f8539a3..102b18f5749 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3397,11 +3397,11 @@ export const zhTW: Record = { "integrations.lazycodexRoles.omoFailed": "{role} 現在使用 {model} 執行,但無法寫入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", "integrations.lazycodexRoles.history.title": "變更紀錄", - "integrations.lazycodexRoles.history.hint": "本區段中的每次儲存與自動分配都會記錄在這裡。還原會像一般儲存一樣寫回先前的模型與推理強度,這次操作本身也會被記錄。原先沒有固定模型的角色保留目前模型,只還原推理強度。", + "integrations.lazycodexRoles.history.hint": "本區段中最近 50 次儲存與自動分配會以整次操作記錄在這裡。還原會像一般儲存一樣寫回先前的模型與推理強度,這次操作本身也會被記錄。原先沒有固定模型的角色保留目前模型,只還原推理強度。", "integrations.lazycodexRoles.history.unavailable": "無法讀取變更記錄,因此無法還原。角色編輯仍可正常使用。", "integrations.lazycodexRoles.history.restore": "還原", "integrations.lazycodexRoles.history.restored": "已將 {count} 個角色還原到 {time} 之前的狀態。", - "integrations.lazycodexRoles.history.partial": "還原在 {role} 停止:{total} 個角色中已還原 {count} 個,其餘保留目前的值。請先修正上方的錯誤,再重新還原。", + "integrations.lazycodexRoles.history.partial": "在 {role} 處停止還原:{total} 個角色中已還原 {count} 個,其餘保留目前的值。請先修正上方的錯誤,再重試還原。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有註解,因此在這裡儲存只會更新角色檔案。請自行設定 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自動指派", "integrations.lazycodexRoles.auto.running": "正在評估角色…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7c899e49ef4..38bebc8b9b6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1923,11 +1923,11 @@ export const zh: Record = { "integrations.lazycodexRoles.omoFailed": "{role} 现在使用 {model} 运行,但无法写入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", "integrations.lazycodexRoles.history.title": "变更记录", - "integrations.lazycodexRoles.history.hint": "本节中的每次保存和自动分配都会记录在这里。恢复会像普通保存一样写回之前的模型和推理强度,这次操作本身也会被记录。原先没有固定模型的角色保留当前模型,只恢复推理强度。", + "integrations.lazycodexRoles.history.hint": "本节中最近 50 次保存和自动分配会以整次操作记录在这里。恢复会像普通保存一样写回之前的模型和推理强度,这次操作本身也会被记录。原先没有固定模型的角色保留当前模型,只恢复推理强度。", "integrations.lazycodexRoles.history.unavailable": "无法读取变更记录,因此无法恢复。角色编辑仍可正常使用。", "integrations.lazycodexRoles.history.restore": "恢复", "integrations.lazycodexRoles.history.restored": "已将 {count} 个角色恢复到 {time} 之前的状态。", - "integrations.lazycodexRoles.history.partial": "恢复在 {role} 停止:{total} 个角色中已恢复 {count} 个,其余保留当前的值。请先修复上方的错误,再重新恢复。", + "integrations.lazycodexRoles.history.partial": "在 {role} 处停止恢复:{total} 个角色中已恢复 {count} 个,其余保留当前的值。请先修复上方的错误,再重试恢复。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有注释,因此在这里保存只会更新角色文件。请自行设置 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自动分配", "integrations.lazycodexRoles.auto.running": "正在评估角色…", diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx index 9c656f9d41e..b8e80535927 100644 --- a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useT } from "../../i18n/shared"; +import { useI18n } from "../../i18n/shared"; import { Notice } from "../../ui"; export interface RoleSetting { @@ -32,7 +32,7 @@ function earliestBefore(group: RoleChange[]): Map { } export default function LazyCodexRoleHistory({ history, unavailable, busy, restore }: { history: RoleChange[]; unavailable: boolean; busy: boolean; restore: Restore }) { - const t = useT(); + const { locale, t } = useI18n(); const [restoring, setRestoring] = useState(null); const [summary, setSummary] = useState<{ tone: "ok" | "warn"; text: string } | null>(null); if (unavailable) return {t("integrations.lazycodexRoles.history.unavailable")}; @@ -55,7 +55,7 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto if (!(await restore(role, setting.model ?? undefined, setting.effort, batch))) break; count += 1; } - const time = new Date(group[0]!.at).toLocaleString(); + const time = new Date(group[0]!.at).toLocaleString(locale); setSummary(count === roles.length ? { tone: "ok", text: t("integrations.lazycodexRoles.history.restored", { count: String(count), time }) } : { tone: "warn", text: t("integrations.lazycodexRoles.history.partial", { count: String(count), total: String(roles.length), role: roles[count]![0] }) }); @@ -70,7 +70,7 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto
      {groupByBatch(history).map(group => (
    • - {new Date(group[0]!.at).toLocaleString()} + {new Date(group[0]!.at).toLocaleString(locale)}
        {group.map(change => (
      • From d2ffedb926170f83adfb91cf67a01c2643006042 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Mon, 26 Oct 2026 21:08:33 +0900 Subject: [PATCH 10/16] fix(codex): keep role history and Restore to the Codex home that wrote it - each history row records the canonical Codex home, and the list and Restore read only the current home's rows, so a proxy restarted against another home no longer shows or writes the first home's earlier models - a Restore PUT names the batch it puts back in restores, and the server refuses it with 409 restore_not_in_history unless this home's history holds that batch for the role with exactly those values - a two-home regression test drives the list and both refused and accepted restores --- .../src/content/docs/guides/integrations.md | 2 +- .../integrations/LazyCodexRoleHistory.tsx | 5 ++- .../integrations/LazyCodexRoleModels.tsx | 4 +- gui/tests/lazycodex-role-models.test.tsx | 4 +- src/codex/role-model-history.ts | 42 ++++++++++++++----- .../management/codex-agent-role-routes.ts | 28 +++++++++++-- structure/subagents.md | 2 +- tests/server/codex-agent-role-routes.test.ts | 35 +++++++++++++--- 8 files changed, 96 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 521ece7ca49..28cda8cb2f6 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. +Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. ## Kilo diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx index b8e80535927..317f7b6359b 100644 --- a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -15,7 +15,8 @@ export interface RoleChange { after: RoleSetting; } -type Restore = (role: string, model: string | undefined, effort: string | null, batch: string) => Promise; +/** `restores` names the batch being put back; the server checks the write against its own history. */ +type Restore = (role: string, model: string | undefined, effort: string | null, batch: string, restores: string) => Promise; /** Newest first, as the server lists them; one entry per batch, so Apply all reads as one action. */ function groupByBatch(history: RoleChange[]): RoleChange[][] { @@ -52,7 +53,7 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror for (const [role, setting] of roles) { // A role that had no model pin keeps its current one: a save can set a pin, not remove it. - if (!(await restore(role, setting.model ?? undefined, setting.effort, batch))) break; + if (!(await restore(role, setting.model ?? undefined, setting.effort, batch, group[0]!.batch))) break; count += 1; } const time = new Date(group[0]!.at).toLocaleString(locale); diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx index b55f2667eb1..b4b29f4809a 100644 --- a/gui/src/pages/integrations/LazyCodexRoleModels.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -85,6 +85,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri effort: string | null | undefined = undefined, displayedEffort?: string, batch?: string, + restores?: string, ): Promise => { if (pending !== null) return false; // An omitted effort still carries a pending omo.jsonc retry's effort, so a later save from any @@ -101,6 +102,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri ...(model !== undefined ? { model } : {}), ...(sent !== undefined ? { effort: sent } : {}), ...(batch !== undefined ? { batch } : {}), + ...(restores !== undefined ? { restores } : {}), }), }); const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( @@ -258,7 +260,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri history={data.history} unavailable={data.historyUnavailable} busy={pending !== null} - restore={(role, model, effort, batch) => save(role, model, effort, undefined, batch)} + restore={(role, model, effort, batch, restores) => save(role, model, effort, undefined, batch, restores)} /> )} diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index fe4a694c4b0..35eb4376767 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -552,8 +552,8 @@ test("lists the role change history as one entry per batch and restores a batch' await act(async () => { restore.click(); }); await settle(); expect(puts.map(put => ({ role: put.url.slice(put.url.lastIndexOf("/") + 1), body: put.body }))).toEqual([ - { role: "librarian", body: { effort: "medium", batch: (puts[0]!.body as { batch: string }).batch } }, - { role: "explorer", body: { model: "gpt-5.5", effort: null, batch: (puts[0]!.body as { batch: string }).batch } }, + { role: "librarian", body: { effort: "medium", batch: (puts[0]!.body as { batch: string }).batch, restores: "apply-all" } }, + { role: "explorer", body: { model: "gpt-5.5", effort: null, batch: (puts[0]!.body as { batch: string }).batch, restores: "apply-all" } }, ]); expect((puts[0]!.body as { batch: string }).batch).not.toBe("apply-all"); expect(container.textContent).toContain("Restored 2 role(s)"); diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index c5be9d04d48..ab80dba4665 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -4,6 +4,8 @@ * role write spans `$CODEX_HOME/agents/.toml` and LazyCodex's omo.jsonc mirror. So the * values are recorded rather than bytes, and a restore is an ordinary write of the earlier values. * Writes made in one action (auto-assign's Apply all) share a batch and are restored together. + * The file sits under the opencodex config directory, which outlives a switch of Codex home, so every + * row names the canonical Codex home it was written in and only that home's rows are listed or restored. */ import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -22,6 +24,10 @@ export interface RoleModelChange { readonly after: RoleSetting; } +interface StoredRoleModelChange extends RoleModelChange { + readonly codexHome: string; +} + export const ROLE_HISTORY_LIMIT = 50; const BATCH_PATTERN = /^[A-Za-z0-9-]{1,64}$/; @@ -33,9 +39,11 @@ export function isRoleHistoryBatch(value: unknown): value is string { return typeof value === "string" && BATCH_PATTERN.test(value); } -export function recordRoleModelChange(change: RoleModelChange, dir: string = integrationsDir()): void { +/** `codexHome` is the canonical path `getCodexHome()` returns, so two spellings of one home match. */ +export function recordRoleModelChange(codexHome: string, change: RoleModelChange, dir: string = integrationsDir()): void { ensureDir(historyPath(dir)); - appendFileSync(historyPath(dir), `${JSON.stringify(change)}\n`, { encoding: "utf8", mode: 0o600 }); + const stored: StoredRoleModelChange = { ...change, codexHome }; + appendFileSync(historyPath(dir), `${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); } function isSetting(value: unknown): value is RoleSetting { @@ -45,32 +53,44 @@ function isSetting(value: unknown): value is RoleSetting { && (setting.effort === null || typeof setting.effort === "string"); } -function parseChange(line: string): RoleModelChange | null { +function parseChange(line: string, codexHome: string): RoleModelChange | null { let value: unknown; try { value = JSON.parse(line); } catch { return null; } - const change = value as RoleModelChange | null; - return typeof change === "object" && change !== null && typeof change.at === "string" && typeof change.role === "string" + const change = value as StoredRoleModelChange | null; + return typeof change === "object" && change !== null && change.codexHome === codexHome + && typeof change.at === "string" && typeof change.role === "string" && isRoleHistoryBatch(change.batch) && isSetting(change.before) && isSetting(change.after) - ? change + ? { at: change.at, batch: change.batch, role: change.role, before: change.before, after: change.after } : null; } /** - * Newest first, in whole batches: the newest `limit` changes plus the rest of any batch they cut into, - * so Restore never puts back part of an Apply all. A torn or foreign line is skipped, the way the - * integration journal reads its own. + * This Codex home's changes, newest first, in whole batches: the newest `limit` changes plus the rest + * of any batch they cut into, so Restore never puts back part of an Apply all. A torn or foreign line, + * and a row another Codex home wrote, is skipped, the way the integration journal reads its own. */ -export function listRoleModelChanges(limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { +export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { const path = historyPath(dir); if (!existsSync(path)) return []; const newestFirst = readFileSync(path, "utf8").split("\n").flatMap(line => { - const change = line.trim() ? parseChange(line) : null; + const change = line.trim() ? parseChange(line, codexHome) : null; return change ? [change] : []; }).reverse(); const kept = new Set(newestFirst.slice(0, limit).map(change => change.batch)); return newestFirst.filter((change, index) => index < limit || kept.has(change.batch)); } + +/** + * The setting a Restore of `batch` may put back on `role` in this Codex home: the role's earliest + * `before` in that batch, or null when this home's listed history has no such change. The server + * checks a restore against it, so a dashboard still showing another home's history cannot write + * that home's earlier models here. + */ +export function restorableSetting(codexHome: string, batch: string, role: string, dir: string = integrationsDir()): RoleSetting | null { + const changes = listRoleModelChanges(codexHome, ROLE_HISTORY_LIMIT, dir).filter(change => change.batch === batch && change.role === role); + return changes.at(-1)?.before ?? null; +} diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index 1c7aef81825..61f837e02b9 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -40,7 +40,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis let changes: import("../../codex/role-model-history").RoleModelChange[] = []; let historyUnavailable = false; try { - changes = history.listRoleModelChanges(); + changes = history.listRoleModelChanges(codexHome); } catch (error) { console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); historyUnavailable = true; @@ -100,7 +100,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis } catch { return jsonResponse({ error: "role must be URL-encoded", code: "invalid_role" }, 400, req, config); } - let body: { model?: unknown; effort?: unknown; batch?: unknown }; + let body: { model?: unknown; effort?: unknown; batch?: unknown; restores?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { @@ -117,6 +117,9 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (body.batch !== undefined && !history.isRoleHistoryBatch(body.batch)) { return jsonResponse({ error: "batch must be 1 to 64 letters, digits or dashes", code: "invalid_batch" }, 400, req, config); } + if (body.restores !== undefined && !history.isRoleHistoryBatch(body.restores)) { + return jsonResponse({ error: "restores must be 1 to 64 letters, digits or dashes", code: "invalid_restores" }, 400, req, config); + } let model: string | undefined; let effort: string | null | undefined; let toml: { status: "written" | "unchanged" }; @@ -124,6 +127,25 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis try { model = body?.model === undefined ? undefined : roles.validateAgentRoleModel(body.model); effort = body?.effort === undefined ? undefined : body.effort === null ? null : roles.validateAgentRoleEffort(body.effort); + } catch (error) { + if (error instanceof roles.AgentRoleModelError) return jsonResponse({ error: error.message, code: error.code }, 400, req, config); + throw error; + } + // A Restore names the batch it puts back, and only this Codex home's history can vouch for it: the + // write must be that role's earliest `before` in the batch, so a stale page cannot carry models + // across homes. A role that had no model pin is restored without a model, since a save keeps a pin. + if (typeof body.restores === "string") { + let allowed: import("../../codex/role-model-history").RoleSetting | null; + try { + allowed = history.restorableSetting(codexHome, body.restores, role); + } catch { + allowed = null; + } + if (!allowed || model !== (allowed.model ?? undefined) || effort !== allowed.effort) { + return jsonResponse({ error: "this Codex home's change history has no such change to restore", code: "restore_not_in_history" }, 409, req, config); + } + } + try { before = { model: roles.listCodexAgentRoleModels(codexHome).find(entry => entry.role === role)?.model ?? null, effort: roles.readCodexAgentRoleEffort(role, codexHome), @@ -139,7 +161,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis } if (toml.status === "written") { try { - history.recordRoleModelChange({ + history.recordRoleModelChange(codexHome, { at: new Date().toISOString(), batch: body.batch ?? crypto.randomUUID(), role, diff --git a/structure/subagents.md b/structure/subagents.md index 04d85bb5dce..fff9725ca2b 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,7 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. -- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, and model and effort before and after. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. `GET /api/codex-agent-roles` returns the newest 50 rows as `history`, widened to the whole of any batch those rows cut into, when LazyCodex is detected; a history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. `GET /api/codex-agent-roles` returns the current Codex home's newest 50 rows as `history`, widened to the whole of any batch those rows cut into, when LazyCodex is detected; rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's listed history holds that batch for that role with exactly those earlier values. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index 73550818a39..720a0d89432 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -41,11 +41,11 @@ afterEach(() => { const config = { port: 10100, providers: {}, defaultProvider: "openai" } as unknown as OcxConfig; const DETECTED = { detected: true, pluginEnabled: true, pluginInstalled: true }; -function installLazyCodex(): void { - const plugin = join(root, "codex", "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); +function installLazyCodex(home = join(root, "codex")): void { + const plugin = join(home, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); mkdirSync(plugin, { recursive: true }); writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); - writeFileSync(join(root, "codex", "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); + writeFileSync(join(home, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); } async function call(path: string, init?: RequestInit): Promise<{ status: number; body: Record }> { @@ -59,13 +59,38 @@ function put(role: string, model: unknown) { } describe("/api/codex-agent-roles", () => { + test("lists and restores only the history of the Codex home the proxy runs against", async () => { + const homeA = join(root, "codex"); + const homeB = join(root, "codex-b"); + mkdirSync(join(homeB, "agents"), { recursive: true }); + writeFileSync(join(homeB, "agents", "explorer.toml"), ROLE); + installLazyCodex(homeB); + const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); + expect((await write({ model: "xai/grok-4.5", batch: "home-a" })).status).toBe(200); + const restore = { model: "gpt-5.5", effort: null, batch: "restore-1", restores: "home-a" }; + + // The proxy restarts against a second home with a role of the same name. + process.env.CODEX_HOME = homeB; + expect((await call("/api/codex-agent-roles")).body.history).toEqual([]); + expect(await write(restore)).toMatchObject({ status: 409, body: { code: "restore_not_in_history" } }); + expect(readFileSync(join(homeB, "agents", "explorer.toml"), "utf8")).toBe(ROLE); + + process.env.CODEX_HOME = homeA; + const history = (await call("/api/codex-agent-roles")).body.history as Array>; + expect(history.map(change => change.batch)).toEqual(["home-a"]); + expect(history[0]).not.toHaveProperty("codexHome"); + expect(await write({ ...restore, model: "a/other" })).toMatchObject({ status: 409, body: { code: "restore_not_in_history" } }); + expect((await write(restore)).status).toBe(200); + expect(readFileSync(join(homeA, "agents", "explorer.toml"), "utf8")).toBe(ROLE); + }); + test("returns whole batches, so a batch cut by the newest 50 is listed with every role it wrote", async () => { const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); const dir = join(root, "history"); - const write = (batch: string, role: string) => recordRoleModelChange({ at: "2026-10-24T00:00:00.000Z", batch, role, before: { model: null, effort: null }, after: { model: "m", effort: null } }, dir); + const write = (batch: string, role: string) => recordRoleModelChange("home", { at: "2026-10-24T00:00:00.000Z", batch, role, before: { model: null, effort: null }, after: { model: "m", effort: null } }, dir); for (let index = 0; index < 3; index++) write("older", `older-${index}`); for (let index = 0; index < ROLE_HISTORY_LIMIT + 1; index++) write("apply-all", `role-${index}`); - const history = listRoleModelChanges(ROLE_HISTORY_LIMIT, dir); + const history = listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir); expect(history.filter(change => change.batch === "apply-all")).toHaveLength(ROLE_HISTORY_LIMIT + 1); expect(history.some(change => change.batch === "older")).toBe(false); }); From 2b017a3aa0779803af0142af37942655568a5bb6 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Mon, 26 Oct 2026 21:17:55 +0900 Subject: [PATCH 11/16] fix(codex): bound the role history file, its reads and its batches - an append that takes the history past 256 KiB rewrites it to its newest whole batches within half that, so the file stops growing with every save - a read looks at no more than the last 256 KiB and 1000 rows instead of parsing the whole file, and drops the oldest batch the read cut into - a batch holds at most 50 rows, refused with 409 batch_full before the role file changes, and a listing takes whole batches newest first and never returns more than 50 rows - tests cover the byte budget, the tail-only read, the batch cap and a reused batch id that used to return 120 rows --- .../src/content/docs/guides/integrations.md | 2 +- src/codex/role-model-history.ts | 126 +++++++++++++++--- .../management/codex-agent-role-routes.ts | 13 ++ structure/subagents.md | 2 +- tests/server/codex-agent-role-routes.test.ts | 78 ++++++++++- 5 files changed, 198 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 28cda8cb2f6..7e5fd2005ad 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists the newest 50 changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first and in whole batches, so an Apply all that reaches into those 50 is listed with every role it wrote, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. +Under the table, **Change history** lists the newest changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, in whole batches and never more than 50, so an Apply all is listed with every role it wrote or not at all, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory, which keeps itself under 256 KiB by dropping its oldest whole batches; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. ## Kilo diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index ab80dba4665..86d5835aa1e 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -6,9 +6,13 @@ * Writes made in one action (auto-assign's Apply all) share a batch and are restored together. * The file sits under the opencodex config directory, which outlives a switch of Codex home, so every * row names the canonical Codex home it was written in and only that home's rows are listed or restored. + * The file is bounded three ways: a batch holds at most ROLE_HISTORY_LIMIT rows, an append that takes + * the file past ROLE_HISTORY_MAX_BYTES rewrites it to its newest whole batches, and a read looks at + * no more than that many bytes and ROLE_HISTORY_MAX_ROWS rows from the end. */ -import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync } from "node:fs"; import { join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; import { ensureDir, integrationsDir } from "../integrations/ownership"; export interface RoleSetting { @@ -28,7 +32,10 @@ interface StoredRoleModelChange extends RoleModelChange { readonly codexHome: string; } +/** Rows one listing returns, and rows one batch may hold, so a whole batch always fits a listing. */ export const ROLE_HISTORY_LIMIT = 50; +export const ROLE_HISTORY_MAX_BYTES = 256 * 1024; +export const ROLE_HISTORY_MAX_ROWS = 1000; const BATCH_PATTERN = /^[A-Za-z0-9-]{1,64}$/; function historyPath(dir: string): string { @@ -44,6 +51,36 @@ export function recordRoleModelChange(codexHome: string, change: RoleModelChange ensureDir(historyPath(dir)); const stored: StoredRoleModelChange = { ...change, codexHome }; appendFileSync(historyPath(dir), `${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); + if (statSync(historyPath(dir)).size <= ROLE_HISTORY_MAX_BYTES) return; + try { + compact(dir); + } catch (cause) { + // Reads stay bounded without the trim; the next append retries it. + throw new Error("recorded the change but could not trim the history file", { cause }); + } +} + +/** + * Rewrite the file to its newest rows within half the byte budget and the row budget, so the next + * compaction is many saves away. A batch the cut falls inside is dropped whole, for every Codex home, + * so no home is left with part of an Apply all to restore. + */ +function compact(dir: string): void { + const rows = tailRows(historyPath(dir)); + const kept: StoredRoleModelChange[] = []; + let bytes = 0; + for (const row of rows) { + bytes += Buffer.byteLength(JSON.stringify(row)) + 1; + if (bytes > ROLE_HISTORY_MAX_BYTES / 2 || kept.length >= ROLE_HISTORY_MAX_ROWS) break; + kept.push(row); + } + const cut = new Set(rows.slice(kept.length).map(batchKey)); + const whole = kept.filter(row => !cut.has(batchKey(row))).reverse(); + atomicWriteFile(historyPath(dir), whole.map(row => `${JSON.stringify(row)}\n`).join("")); +} + +function batchKey(row: StoredRoleModelChange): string { + return `${row.codexHome}\n${row.batch}`; } function isSetting(value: unknown): value is RoleSetting { @@ -53,7 +90,7 @@ function isSetting(value: unknown): value is RoleSetting { && (setting.effort === null || typeof setting.effort === "string"); } -function parseChange(line: string, codexHome: string): RoleModelChange | null { +function parseRow(line: string): StoredRoleModelChange | null { let value: unknown; try { value = JSON.parse(line); @@ -61,27 +98,86 @@ function parseChange(line: string, codexHome: string): RoleModelChange | null { return null; } const change = value as StoredRoleModelChange | null; - return typeof change === "object" && change !== null && change.codexHome === codexHome + return typeof change === "object" && change !== null && typeof change.codexHome === "string" && typeof change.at === "string" && typeof change.role === "string" && isRoleHistoryBatch(change.batch) && isSetting(change.before) && isSetting(change.after) - ? { at: change.at, batch: change.batch, role: change.role, before: change.before, after: change.after } + ? change : null; } /** - * This Codex home's changes, newest first, in whole batches: the newest `limit` changes plus the rest - * of any batch they cut into, so Restore never puts back part of an Apply all. A torn or foreign line, - * and a row another Codex home wrote, is skipped, the way the integration journal reads its own. + * Every Codex home's rows, newest first, from at most the last ROLE_HISTORY_MAX_BYTES of the file and + * at most ROLE_HISTORY_MAX_ROWS of them. When the read starts mid-file, its first partial line is + * skipped and so is the batch of its oldest row, which may continue above the read. */ -export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { - const path = historyPath(dir); - if (!existsSync(path)) return []; - const newestFirst = readFileSync(path, "utf8").split("\n").flatMap(line => { - const change = line.trim() ? parseChange(line, codexHome) : null; - return change ? [change] : []; +function tailRows(path: string): StoredRoleModelChange[] { + let fd: number; + try { + fd = openSync(path, "r"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + let text: string; + let truncated: boolean; + try { + const stat = fstatSync(fd); + // Windows opens a directory for reading, so the type is checked rather than left to the read. + if (!stat.isFile()) throw new Error(`${path} is not a regular file`); + const size = stat.size; + const start = Math.max(0, size - ROLE_HISTORY_MAX_BYTES - 1); + const bytes = Buffer.alloc(size - start); + let length = 0; + while (length < bytes.length) { + const count = readSync(fd, bytes, length, bytes.length - length, start + length); + if (count === 0) break; + length += count; + } + truncated = start > 0; + const read = bytes.subarray(0, length); + text = (truncated ? read.subarray(read.indexOf(10) + 1) : read).toString("utf8"); + } finally { + closeSync(fd); + } + const lines = text.split("\n").filter(line => line.trim()); + truncated ||= lines.length > ROLE_HISTORY_MAX_ROWS; + const rows = lines.slice(-ROLE_HISTORY_MAX_ROWS).flatMap(line => { + const row = parseRow(line); + return row ? [row] : []; }).reverse(); - const kept = new Set(newestFirst.slice(0, limit).map(change => change.batch)); - return newestFirst.filter((change, index) => index < limit || kept.has(change.batch)); + const partial = truncated ? rows.at(-1) : undefined; + return partial ? rows.filter(row => batchKey(row) !== batchKey(partial)) : rows; +} + +function forHome(rows: StoredRoleModelChange[], codexHome: string): RoleModelChange[] { + return rows.flatMap(({ codexHome: home, ...change }) => home === codexHome ? [change] : []); +} + +/** + * This Codex home's changes, newest first, in whole batches and never more than `limit` rows: batches + * are taken newest first until the next would not fit, so Restore never puts back part of an Apply all. + * A batch larger than `limit` (written before batches were capped) can never be listed whole and is + * skipped. A torn or foreign line, and a row another Codex home wrote, is skipped, the way the + * integration journal reads its own. + */ +export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { + const newestFirst = forHome(tailRows(historyPath(dir)), codexHome); + const sizes = new Map(); + for (const change of newestFirst) sizes.set(change.batch, (sizes.get(change.batch) ?? 0) + 1); + const listed = new Set(); + let count = 0; + for (const [batch, size] of sizes) { + if (size > limit) continue; + if (count + size > limit) break; + listed.add(batch); + count += size; + } + return newestFirst.filter(change => listed.has(change.batch)); +} + +/** How many rows this Codex home's history holds in `batch`; a write is refused once it reaches the cap. */ +export function roleHistoryBatchSize(codexHome: string, batch: string, dir: string = integrationsDir()): number { + return forHome(tailRows(historyPath(dir)), codexHome).filter(change => change.batch === batch).length; } /** diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index 61f837e02b9..13843fa4ca6 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -120,6 +120,19 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (body.restores !== undefined && !history.isRoleHistoryBatch(body.restores)) { return jsonResponse({ error: "restores must be 1 to 64 letters, digits or dashes", code: "invalid_restores" }, 400, req, config); } + // A batch is capped at the listing size, so reusing one id cannot grow a batch the history could + // never list or restore whole. History is optional: a file that cannot be read does not block saves. + if (typeof body.batch === "string") { + let size = 0; + try { + size = history.roleHistoryBatchSize(codexHome, body.batch); + } catch (error) { + console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); + } + if (size >= history.ROLE_HISTORY_LIMIT) { + return jsonResponse({ error: `a batch holds at most ${history.ROLE_HISTORY_LIMIT} changes`, code: "batch_full" }, 409, req, config); + } + } let model: string | undefined; let effort: string | null | undefined; let toml: { status: "written" | "unchanged" }; diff --git a/structure/subagents.md b/structure/subagents.md index fff9725ca2b..2f68ed8edf7 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,7 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. -- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. `GET /api/codex-agent-roles` returns the current Codex home's newest 50 rows as `history`, widened to the whole of any batch those rows cut into, when LazyCodex is detected; rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's listed history holds that batch for that role with exactly those earlier values. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. A batch holds at most 50 rows: a write into a batch the current home's history already holds 50 times is refused with 409 `batch_full` before the role file is touched. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: an append that takes it past 256 KiB rewrites it to its newest whole batches within half that, and a read looks at no more than the last 256 KiB and 1000 rows, skipping the oldest batch the read cut into. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's listed history holds that batch for that role with exactly those earlier values. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index 720a0d89432..45601fc5d30 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -84,15 +84,81 @@ describe("/api/codex-agent-roles", () => { expect(readFileSync(join(homeA, "agents", "explorer.toml"), "utf8")).toBe(ROLE); }); - test("returns whole batches, so a batch cut by the newest 50 is listed with every role it wrote", async () => { + function historyRow(batch: string, role: string, model = "m"): import("../../src/codex/role-model-history").RoleModelChange { + return { at: "2026-10-24T00:00:00.000Z", batch, role, before: { model: null, effort: null }, after: { model, effort: null } }; + } + + test("lists whole batches and never more than 50 rows, even when one batch id was reused past its cap", async () => { const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); const dir = join(root, "history"); - const write = (batch: string, role: string) => recordRoleModelChange("home", { at: "2026-10-24T00:00:00.000Z", batch, role, before: { model: null, effort: null }, after: { model: "m", effort: null } }, dir); - for (let index = 0; index < 3; index++) write("older", `older-${index}`); - for (let index = 0; index < ROLE_HISTORY_LIMIT + 1; index++) write("apply-all", `role-${index}`); + for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("a", `a-${index}`), dir); + for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("b", `b-${index}`), dir); + expect(listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir).map(change => change.batch)).toEqual(Array(30).fill("b")); + // A file written before the batch cap can hold one batch larger than the whole response. + for (let index = 0; index < 120; index++) recordRoleModelChange("home", historyRow("reused", `r-${index}`), dir); const history = listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir); - expect(history.filter(change => change.batch === "apply-all")).toHaveLength(ROLE_HISTORY_LIMIT + 1); - expect(history.some(change => change.batch === "older")).toBe(false); + expect(history.length).toBeLessThanOrEqual(ROLE_HISTORY_LIMIT); + expect(history.map(change => change.batch)).toEqual(Array(30).fill("b")); + }); + + test("a write into a batch that already holds 50 rows is refused before the role file changes", async () => { + const { recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); + const dir = join(root, "ocx", "integrations"); + for (let index = 0; index < ROLE_HISTORY_LIMIT; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `role-${index}`), dir); + const refused = await call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify({ model: "xai/grok-4.5", batch: "full" }) }); + expect(refused).toMatchObject({ status: 409, body: { code: "batch_full" } }); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + expect((await call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify({ model: "xai/grok-4.5", batch: "next" }) })).status).toBe(200); + }); + + test("keeps the history file within its byte budget by dropping the oldest whole batches", async () => { + const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); + const dir = join(root, "history"); + const wide = "m".repeat(4000); + let batches = 0; + for (let written = 0; written < ROLE_HISTORY_MAX_BYTES * 3; written += 3 * wide.length, batches += 1) { + for (let role = 0; role < 3; role++) recordRoleModelChange("home", historyRow(`batch-${batches}`, `role-${role}`, wide), dir); + } + const path = join(dir, "codex-agent-roles.jsonl"); + expect(fs.statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); + const rows = readFileSync(path, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line) as { batch: string }); + const sizes = new Map(); + for (const row of rows) sizes.set(row.batch, (sizes.get(row.batch) ?? 0) + 1); + expect([...sizes.values()].every(size => size === 3)).toBe(true); + expect(rows.at(-1)!.batch).toBe(`batch-${batches - 1}`); + expect(sizes.has("batch-0")).toBe(false); + expect(listRoleModelChanges("home", 50, dir)[0]!.batch).toBe(`batch-${batches - 1}`); + }); + + test("reads only the tail of an oversized history file", async () => { + const { listRoleModelChanges, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); + const dir = join(root, "history"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "codex-agent-roles.jsonl"); + const line = (batch: string, role: string) => `${JSON.stringify({ ...historyRow(batch, role), codexHome: "home" })}\n`; + // An oversized file from before retention, or one a failed compaction left behind. + writeFileSync(path, line("ancient", "explorer").repeat(Math.ceil((ROLE_HISTORY_MAX_BYTES * 4) / line("ancient", "explorer").length)) + line("newest", "explorer")); + const nativeReadFile = fs.readFileSync; + const nativeRead = fs.readSync; + let wholeReads = 0; + let bytesRead = 0; + const readFile = spyOn(fs, "readFileSync").mockImplementation(((target: fs.PathOrFileDescriptor, options?: unknown) => { + if (String(target) === path) wholeReads += 1; + return nativeReadFile(target, options as BufferEncoding); + }) as never); + const read = spyOn(fs, "readSync").mockImplementation(((...args: Parameters) => { + const count = (nativeRead as (...rest: unknown[]) => number)(...args); + bytesRead += count; + return count; + }) as never); + try { + expect(listRoleModelChanges("home", 50, dir)[0]!.batch).toBe("newest"); + } finally { + readFile.mockRestore(); + read.mockRestore(); + } + expect(wholeReads).toBe(0); + expect(bytesRead).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES + 1); }); test("an unreadable history file leaves the role list and role saves working", async () => { From 713519a328fa6477985d91243752296ff8bf8b34 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Mon, 26 Oct 2026 21:26:10 +0900 Subject: [PATCH 12/16] fix(gui): let Apply all and Restore run without crypto.randomUUID - Apply all and Restore take their batch id from newClientId, which falls back to getRandomValues on a dashboard served over plain HTTP on the LAN, where randomUUID is missing or throws - both handlers clear their busy state in finally, so a failure can no longer leave the panel or the Restore button locked - GUI tests run Apply all and Restore with randomUUID absent and throwing --- .../integrations/LazyCodexRoleAutoAssign.tsx | 21 ++++++++----- .../integrations/LazyCodexRoleHistory.tsx | 31 +++++++++++-------- gui/tests/lazycodex-role-auto-assign.test.tsx | 19 +++++++++++- gui/tests/lazycodex-role-models.test.tsx | 25 ++++++++++++++- 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx index 17b75e45592..49248d57ee6 100644 --- a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { readJsonOrThrow } from "../../fetch-json"; import { useT } from "../../i18n/shared"; import { Notice } from "../../ui"; +import { newClientId } from "../claude-code-types"; import { EFFORT_LABEL, TIER_LABEL, type SizingEffortIntent, type SizingTier } from "./sizing-labels"; export interface RoleProposal { @@ -78,14 +79,18 @@ export default function LazyCodexRoleAutoAssign({ const applyAll = async () => { setApplying(true); - let count = 0; - const total = applicable.length; - // One batch, so the role history restores the whole Apply all at once. - const batch = crypto.randomUUID(); - // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror - for (const proposal of applicable) if (await applyOne(proposal, batch)) count += 1; - setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); - setApplying(false); + try { + let count = 0; + const total = applicable.length; + // One batch, so the role history restores the whole Apply all at once. A dashboard served over + // plain HTTP on the LAN has no crypto.randomUUID. + const batch = newClientId(); + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror + for (const proposal of applicable) if (await applyOne(proposal, batch)) count += 1; + setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); + } finally { + setApplying(false); + } }; const locked = running || applying || busy; diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx index 317f7b6359b..8c268352bdf 100644 --- a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useI18n } from "../../i18n/shared"; import { Notice } from "../../ui"; +import { newClientId } from "../claude-code-types"; export interface RoleSetting { model: string | null; @@ -47,20 +48,24 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto const restoreGroup = async (group: RoleChange[]) => { setRestoring(group[0]!.batch); setSummary(null); - const batch = crypto.randomUUID(); - const roles = [...earliestBefore(group)]; - let count = 0; - // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror - for (const [role, setting] of roles) { - // A role that had no model pin keeps its current one: a save can set a pin, not remove it. - if (!(await restore(role, setting.model ?? undefined, setting.effort, batch, group[0]!.batch))) break; - count += 1; + try { + // A dashboard served over plain HTTP on the LAN has no crypto.randomUUID. + const batch = newClientId(); + const roles = [...earliestBefore(group)]; + let count = 0; + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror + for (const [role, setting] of roles) { + // A role that had no model pin keeps its current one: a save can set a pin, not remove it. + if (!(await restore(role, setting.model ?? undefined, setting.effort, batch, group[0]!.batch))) break; + count += 1; + } + const time = new Date(group[0]!.at).toLocaleString(locale); + setSummary(count === roles.length + ? { tone: "ok", text: t("integrations.lazycodexRoles.history.restored", { count: String(count), time }) } + : { tone: "warn", text: t("integrations.lazycodexRoles.history.partial", { count: String(count), total: String(roles.length), role: roles[count]![0] }) }); + } finally { + setRestoring(null); } - const time = new Date(group[0]!.at).toLocaleString(locale); - setSummary(count === roles.length - ? { tone: "ok", text: t("integrations.lazycodexRoles.history.restored", { count: String(count), time }) } - : { tone: "warn", text: t("integrations.lazycodexRoles.history.partial", { count: String(count), total: String(roles.length), role: roles[count]![0] }) }); - setRestoring(null); }; return ( diff --git a/gui/tests/lazycodex-role-auto-assign.test.tsx b/gui/tests/lazycodex-role-auto-assign.test.tsx index efb500971ec..6c4f2a91c00 100644 --- a/gui/tests/lazycodex-role-auto-assign.test.tsx +++ b/gui/tests/lazycodex-role-auto-assign.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; -const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "crypto", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -257,3 +257,20 @@ test("Apply after a failed effort-only mirror on a model-inheriting role carries ]); expect(roleRow("worker").querySelectorAll("td")[1]!.textContent).toBe("a/mid · low"); }); + +// A dashboard opened over plain HTTP on the LAN is not a secure context: randomUUID is missing or throws. +for (const availability of ["absent", "throws"] as const) { + test(`Apply all writes one batch and unlocks the panel when randomUUID is ${availability}`, async () => { + Object.defineProperty(globalThis, "crypto", { configurable: true, value: availability === "absent" ? {} + : { randomUUID: () => { throw new Error("insecure context"); } } }); + await mount(); + await click(button("Auto-assign")); + await click(button("Apply all")); + const batches = requests.slice(1).map(r => (r.body as { batch?: string }).batch); + expect(batches).toHaveLength(2); + expect(batches[0]).toMatch(/^[0-9a-f-]{36}$/); + expect(batches[1]).toBe(batches[0]); + expect(container.textContent).toContain("Applied 2 of 2 proposals."); + expect(button("Auto-assign").disabled).toBe(false); + }); +} diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index 35eb4376767..4148ac17e8c 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -4,7 +4,7 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import { validateAgentRoleEffort } from "../../src/codex/agent-role-models"; -const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "crypto", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -584,3 +584,26 @@ test("stops a Restore at the first failed role and warns that the batch is only expect(container.textContent).toContain("Restore stopped at librarian: 0 of 2 role(s) went back"); expect(container.textContent).not.toContain("Restored 0 role(s)"); }); + +for (const availability of ["absent", "throws"] as const) { + test(`Restore writes the batch back and frees its button when randomUUID is ${availability}`, async () => { + Object.defineProperty(globalThis, "crypto", { configurable: true, value: availability === "absent" ? {} + : { randomUUID: () => { throw new Error("insecure context"); } } }); + rolesBody = { + ...(rolesBody as Record), + history: [ + { at: "2026-10-22T00:09:35.000Z", batch: "apply-all", role: "librarian", before: { model: null, effort: "medium" }, after: { model: "xai/grok-4.5", effort: "low" } }, + { at: "2026-10-22T00:09:34.000Z", batch: "apply-all", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "low" } }, + ], + }; + await mount(); + const restore = () => container.querySelector(".lazycodex-role-history > ul > li button") as HTMLButtonElement; + await act(async () => { restore().click(); }); + await settle(); + expect(puts).toHaveLength(2); + expect((puts[0]!.body as { batch: string }).batch).toMatch(/^[0-9a-f-]{36}$/); + expect(container.textContent).toContain("Restored 2 role(s)"); + expect(restore().disabled).toBe(false); + expect(restore().textContent).toBe("Restore"); + }); +} From 82a8a6991bc307fefa989028a7c9be0ee9768793 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Tue, 27 Oct 2026 01:27:42 +0900 Subject: [PATCH 13/16] fix(codex): let a Restore finish every role of the batch it restores - the server checks a Restore against this Codex home's whole retained history instead of the 50-row listing, so the rows a Restore of a full 50-row batch appends no longer push that batch out of reach after its first role - a row written by a Restore records the batch it restores, and compaction keeps that batch whole while the Restore's rows are kept, so a compaction between two roles cannot drop the batch either - tests restore both roles of a full 50-row batch and restore through a compaction triggered by the first restored row --- src/codex/role-model-history.ts | 32 ++++++++------ .../management/codex-agent-role-routes.ts | 1 + tests/server/codex-agent-role-routes.test.ts | 43 +++++++++++++++++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index 86d5835aa1e..889ff9922da 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -30,6 +30,8 @@ export interface RoleModelChange { interface StoredRoleModelChange extends RoleModelChange { readonly codexHome: string; + /** The batch this row's write restored, so compaction keeps that batch while the Restore is kept. */ + readonly restores?: string; } /** Rows one listing returns, and rows one batch may hold, so a whole batch always fits a listing. */ @@ -47,7 +49,7 @@ export function isRoleHistoryBatch(value: unknown): value is string { } /** `codexHome` is the canonical path `getCodexHome()` returns, so two spellings of one home match. */ -export function recordRoleModelChange(codexHome: string, change: RoleModelChange, dir: string = integrationsDir()): void { +export function recordRoleModelChange(codexHome: string, change: RoleModelChange & { readonly restores?: string }, dir: string = integrationsDir()): void { ensureDir(historyPath(dir)); const stored: StoredRoleModelChange = { ...change, codexHome }; appendFileSync(historyPath(dir), `${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); @@ -63,23 +65,27 @@ export function recordRoleModelChange(codexHome: string, change: RoleModelChange /** * Rewrite the file to its newest rows within half the byte budget and the row budget, so the next * compaction is many saves away. A batch the cut falls inside is dropped whole, for every Codex home, - * so no home is left with part of an Apply all to restore. + * so no home is left with part of an Apply all to restore. A kept Restore keeps the batch it restores + * whole, so a Restore still writing its roles never loses the batch that vouches for them. */ function compact(dir: string): void { const rows = tailRows(historyPath(dir)); - const kept: StoredRoleModelChange[] = []; + let newest = 0; let bytes = 0; for (const row of rows) { bytes += Buffer.byteLength(JSON.stringify(row)) + 1; - if (bytes > ROLE_HISTORY_MAX_BYTES / 2 || kept.length >= ROLE_HISTORY_MAX_ROWS) break; - kept.push(row); + if (bytes > ROLE_HISTORY_MAX_BYTES / 2 || newest >= ROLE_HISTORY_MAX_ROWS) break; + newest += 1; } - const cut = new Set(rows.slice(kept.length).map(batchKey)); - const whole = kept.filter(row => !cut.has(batchKey(row))).reverse(); + const cut = new Set(rows.slice(newest).map(batchKey)); + const keep = new Set(rows.slice(0, newest).map(batchKey).filter(key => !cut.has(key))); + const sources = rows.flatMap(row => keep.has(batchKey(row)) && row.restores ? [batchKey({ ...row, batch: row.restores })] : []); + for (const source of sources) keep.add(source); + const whole = rows.filter(row => keep.has(batchKey(row))).reverse(); atomicWriteFile(historyPath(dir), whole.map(row => `${JSON.stringify(row)}\n`).join("")); } -function batchKey(row: StoredRoleModelChange): string { +function batchKey(row: Pick): string { return `${row.codexHome}\n${row.batch}`; } @@ -101,6 +107,7 @@ function parseRow(line: string): StoredRoleModelChange | null { return typeof change === "object" && change !== null && typeof change.codexHome === "string" && typeof change.at === "string" && typeof change.role === "string" && isRoleHistoryBatch(change.batch) && isSetting(change.before) && isSetting(change.after) + && (change.restores === undefined || isRoleHistoryBatch(change.restores)) ? change : null; } @@ -150,7 +157,7 @@ function tailRows(path: string): StoredRoleModelChange[] { } function forHome(rows: StoredRoleModelChange[], codexHome: string): RoleModelChange[] { - return rows.flatMap(({ codexHome: home, ...change }) => home === codexHome ? [change] : []); + return rows.flatMap(({ codexHome: home, restores: _restores, ...change }) => home === codexHome ? [change] : []); } /** @@ -182,11 +189,12 @@ export function roleHistoryBatchSize(codexHome: string, batch: string, dir: stri /** * The setting a Restore of `batch` may put back on `role` in this Codex home: the role's earliest - * `before` in that batch, or null when this home's listed history has no such change. The server + * `before` in that batch, or null when this home's retained history has no such change. The server * checks a restore against it, so a dashboard still showing another home's history cannot write - * that home's earlier models here. + * that home's earlier models here. It reads every retained batch rather than the 50-row listing, + * because a Restore's own rows push the batch it restores out of that listing before it finishes. */ export function restorableSetting(codexHome: string, batch: string, role: string, dir: string = integrationsDir()): RoleSetting | null { - const changes = listRoleModelChanges(codexHome, ROLE_HISTORY_LIMIT, dir).filter(change => change.batch === batch && change.role === role); + const changes = forHome(tailRows(historyPath(dir)), codexHome).filter(change => change.batch === batch && change.role === role); return changes.at(-1)?.before ?? null; } diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index 13843fa4ca6..f4f63575600 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -180,6 +180,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis role, before, after: { model: model ?? before.model, effort: effort === undefined ? before.effort : effort }, + ...(typeof body.restores === "string" ? { restores: body.restores } : {}), }); } catch (error) { // The write already stands; a lost history row only costs the Restore button for it. diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index 45601fc5d30..8e028f70ecc 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -111,6 +111,49 @@ describe("/api/codex-agent-roles", () => { expect((await call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify({ model: "xai/grok-4.5", batch: "next" }) })).status).toBe(200); }); + // explorer pinned gpt-5.5 with no effort; librarian inherited its model and held effort high. + async function seedRestorable(dir: string, batch: string, explorerAfter: string, librarianAfter: string): Promise { + const { recordRoleModelChange } = await import("../../src/codex/role-model-history"); + const home = join(root, "codex"); + recordRoleModelChange(home, { ...historyRow(batch, "explorer", explorerAfter), before: { model: "gpt-5.5", effort: null } }, dir); + recordRoleModelChange(home, { ...historyRow(batch, "librarian", librarianAfter), before: { model: null, effort: "high" } }, dir); + } + + test("a Restore of a full 50-row batch writes every role, though its own rows push that batch out of the listing", async () => { + const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); + const dir = join(root, "ocx", "integrations"); + inheritingRole(`model_reasoning_effort = "low"\n${INHERITING}`); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE.replace('model = "gpt-5.5"', 'model = "a/now"')); + for (let index = 0; index < ROLE_HISTORY_LIMIT - 2; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `gone-${index}`), dir); + await seedRestorable(dir, "full", "a/now", "a/now"); + expect(listRoleModelChanges(join(root, "codex"), ROLE_HISTORY_LIMIT, dir)).toHaveLength(ROLE_HISTORY_LIMIT); + const restore = (role: string, body: Record) => roleCall(role, { ...body, restores: "full" }); + expect((await restore("explorer", { model: "gpt-5.5", effort: null })).status).toBe(200); + expect((await restore("librarian", { effort: "high" })).status).toBe(200); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + expect(readFileSync(join(root, "codex", "agents", "librarian.toml"), "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); + }); + + test("a compaction in the middle of a Restore keeps the batch being restored until every role is back", async () => { + const { recordRoleModelChange, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); + const dir = join(root, "ocx", "integrations"); + inheritingRole(`model_reasoning_effort = "low"\n${INHERITING}`); + // Older batches fill most of the byte budget, and the batch to restore holds two wide rows. + for (let index = 0; index < 10; index++) recordRoleModelChange(join(root, "codex"), historyRow(`old-${index}`, "explorer", "o".repeat(10_000)), dir); + await seedRestorable(dir, "wide", "s".repeat(60_000), "t".repeat(60_000)); + // The first restored row records a wide current model, so its append takes the file past the budget. + const current = "c".repeat(50_000); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE.replace('model = "gpt-5.5"', `model = "${current}"`)); + const path = join(dir, "codex-agent-roles.jsonl"); + const before = fs.statSync(path).size; + expect(before + current.length).toBeGreaterThan(ROLE_HISTORY_MAX_BYTES); + const restore = (role: string, body: Record) => roleCall(role, { ...body, restores: "wide" }); + expect((await restore("explorer", { model: "gpt-5.5", effort: null })).status).toBe(200); + expect(fs.statSync(path).size).toBeLessThan(before); + expect((await restore("librarian", { effort: "high" })).status).toBe(200); + expect(readFileSync(join(root, "codex", "agents", "librarian.toml"), "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); + }); + test("keeps the history file within its byte budget by dropping the oldest whole batches", async () => { const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); const dir = join(root, "history"); From 8d08e192e16cbab0df8824dca1276681cd8da41d Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Tue, 27 Oct 2026 02:10:57 +0900 Subject: [PATCH 14/16] fix(codex): keep compaction from resetting or fragmenting a batch - the server hands out batch ids: a write without batch starts one and the response names it, and a write joins a batch only while this home's retained history still holds it whole, else 409 batch_closed, so an id compaction dropped can no longer start over with a middle value as its first - each row stores its number in its batch, so the 50-row cap no longer resets when compaction drops a batch's early rows, and every read drops a batch missing any row instead of listing or restoring the fragment - Apply all and Restore send no batch with their first write and reuse the id its response names, so the dashboard no longer makes batch ids - tests replay the 120-write probe with 4,000-character models, reuse of a compacted or never-issued id, and an interleaved fragment whose first row is gone - structure/subagents.md describes the batch contract and that Restore is checked against retained history --- .../integrations/LazyCodexRoleAutoAssign.tsx | 25 ++-- .../integrations/LazyCodexRoleHistory.tsx | 20 ++- .../integrations/LazyCodexRoleModels.tsx | 17 +-- gui/tests/lazycodex-role-auto-assign.test.tsx | 15 +-- gui/tests/lazycodex-role-models.test.tsx | 10 +- src/codex/role-model-history.ts | 64 +++++++--- .../management/codex-agent-role-routes.ts | 29 +++-- structure/subagents.md | 2 +- tests/server/codex-agent-role-routes.test.ts | 118 ++++++++++++++---- 9 files changed, 210 insertions(+), 90 deletions(-) diff --git a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx index 49248d57ee6..6ac6ebfe1dd 100644 --- a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { readJsonOrThrow } from "../../fetch-json"; import { useT } from "../../i18n/shared"; import { Notice } from "../../ui"; -import { newClientId } from "../claude-code-types"; +import type { SavedRole } from "./LazyCodexRoleHistory"; import { EFFORT_LABEL, TIER_LABEL, type SizingEffortIntent, type SizingTier } from "./sizing-labels"; export interface RoleProposal { @@ -37,7 +37,7 @@ export default function LazyCodexRoleAutoAssign({ }: { apiBase: string; busy: boolean; - apply: (role: string, model: string, effort: string | undefined, batch?: string) => Promise; + apply: (role: string, model: string, effort: string | undefined, batch?: string) => Promise; }) { const t = useT(); const [running, setRunning] = useState(false); @@ -71,10 +71,10 @@ export default function LazyCodexRoleAutoAssign({ const applicable = (result?.proposals ?? []).filter(p => p.status === "proposed" && p.proposedModel && !applied[p.role] && !alreadySet(p)); - const applyOne = async (proposal: RoleProposal, batch?: string): Promise => { - const ok = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? undefined, batch); - if (ok) setApplied(current => ({ ...current, [proposal.role]: true })); - return ok; + const applyOne = async (proposal: RoleProposal, batch?: string): Promise => { + const saved = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? undefined, batch); + if (saved) setApplied(current => ({ ...current, [proposal.role]: true })); + return saved; }; const applyAll = async () => { @@ -82,11 +82,16 @@ export default function LazyCodexRoleAutoAssign({ try { let count = 0; const total = applicable.length; - // One batch, so the role history restores the whole Apply all at once. A dashboard served over - // plain HTTP on the LAN has no crypto.randomUUID. - const batch = newClientId(); + // One batch, so the role history restores the whole Apply all at once: the first recorded write + // starts it and the server names it, and every later write joins it. + let batch: string | undefined; // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror - for (const proposal of applicable) if (await applyOne(proposal, batch)) count += 1; + for (const proposal of applicable) { + const saved = await applyOne(proposal, batch); + if (!saved) continue; + batch ??= saved.batch; + count += 1; + } setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); } finally { setApplying(false); diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx index 8c268352bdf..ac1e18a95a2 100644 --- a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { useI18n } from "../../i18n/shared"; import { Notice } from "../../ui"; -import { newClientId } from "../claude-code-types"; export interface RoleSetting { model: string | null; @@ -16,8 +15,16 @@ export interface RoleChange { after: RoleSetting; } -/** `restores` names the batch being put back; the server checks the write against its own history. */ -type Restore = (role: string, model: string | undefined, effort: string | null, batch: string, restores: string) => Promise; +/** A saved role write: the batch the server recorded it in, absent when it recorded nothing. */ +export interface SavedRole { + batch?: string; +} + +/** + * `batch` joins the batch the first write of this Restore started (the server hands out batch ids); + * `restores` names the batch being put back, and the server checks the write against its own history. + */ +type Restore = (role: string, model: string | undefined, effort: string | null, batch: string | undefined, restores: string) => Promise; /** Newest first, as the server lists them; one entry per batch, so Apply all reads as one action. */ function groupByBatch(history: RoleChange[]): RoleChange[][] { @@ -49,14 +56,15 @@ export default function LazyCodexRoleHistory({ history, unavailable, busy, resto setRestoring(group[0]!.batch); setSummary(null); try { - // A dashboard served over plain HTTP on the LAN has no crypto.randomUUID. - const batch = newClientId(); + let batch: string | undefined; const roles = [...earliestBefore(group)]; let count = 0; // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror for (const [role, setting] of roles) { // A role that had no model pin keeps its current one: a save can set a pin, not remove it. - if (!(await restore(role, setting.model ?? undefined, setting.effort, batch, group[0]!.batch))) break; + const saved = await restore(role, setting.model ?? undefined, setting.effort, batch, group[0]!.batch); + if (!saved) break; + batch ??= saved.batch; count += 1; } const time = new Date(group[0]!.at).toLocaleString(locale); diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx index b4b29f4809a..5949a7a1450 100644 --- a/gui/src/pages/integrations/LazyCodexRoleModels.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -5,7 +5,7 @@ import { readJsonOrThrow } from "../../fetch-json"; import { useT, type TKey } from "../../i18n/shared"; import { Notice, Select, type SelectOption } from "../../ui"; import LazyCodexRoleAutoAssign from "./LazyCodexRoleAutoAssign"; -import LazyCodexRoleHistory, { type RoleChange } from "./LazyCodexRoleHistory"; +import LazyCodexRoleHistory, { type RoleChange, type SavedRole } from "./LazyCodexRoleHistory"; interface RoleRow { role: string; @@ -78,7 +78,8 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri useLayoutEffect(() => { latestData.current = data; }, [data]); // An undefined model saves the effort alone and leaves the role's model pin, or its inheritance, - // as it is. Auto-assign and model picks always pass a model. + // as it is. Auto-assign and model picks always pass a model. A save resolves to the batch the + // server recorded it in (absent when nothing was written or recorded), or null when it failed. const save = async ( role: string, model: string | undefined, @@ -86,12 +87,12 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri displayedEffort?: string, batch?: string, restores?: string, - ): Promise => { - if (pending !== null) return false; + ): Promise => { + if (pending !== null) return null; // An omitted effort still carries a pending omo.jsonc retry's effort, so a later save from any // path (the row or auto-assign) cannot drop it. An explicit effort replaces the retry. const sent = effort === undefined ? mirrorRetries[role]?.sent : effort; - if (model === undefined && sent === undefined) return false; + if (model === undefined && sent === undefined) return null; setPending(role); setResult(null); try { @@ -105,7 +106,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri ...(restores !== undefined ? { restores } : {}), }), }); - const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( + const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus }; batch?: string }>( response, t("integrations.lazycodexRoles.saveFailed", { role }), ); @@ -145,10 +146,10 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri setEffortDrafts(current => keepOnFailure(current, shownEffort)); setMirrorRetries(current => keepOnFailure(current, { sent })); await resource.refresh(); - return true; + return { batch: payload?.batch }; } catch { setResult({ tone: "err", text: t("integrations.lazycodexRoles.saveFailed", { role }) }); - return false; + return null; } finally { setPending(null); } diff --git a/gui/tests/lazycodex-role-auto-assign.test.tsx b/gui/tests/lazycodex-role-auto-assign.test.tsx index 6c4f2a91c00..a39cacc63f6 100644 --- a/gui/tests/lazycodex-role-auto-assign.test.tsx +++ b/gui/tests/lazycodex-role-auto-assign.test.tsx @@ -62,7 +62,8 @@ beforeEach(() => { const body = JSON.parse(String(init?.body)); const role = url.slice(url.lastIndexOf("/") + 1); roleRows = roleRows.map(row => row.role === role ? { ...row, model: body.model ?? row.model, effort: body.effort === undefined ? row.effort : body.effort } : row); - return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: mirrorStatus } }); + // Like the server, a write without a batch starts one and the response names it. + return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: mirrorStatus }, batch: body.batch ?? `server-${requests.length}` }); } if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "a/small", "a/mid"] }); if (url.endsWith("/api/injection-model")) return json({ efforts: ["low", "medium", "high"] }); @@ -144,11 +145,9 @@ test("apply all writes each remaining proposal once and skips unsized roles", as await mount(); await click(button("Auto-assign")); await click(button("Apply all")); - const batch = (requests[1]!.body as { batch: string }).batch; - expect(batch).toMatch(/^[0-9a-f-]{36}$/); expect(requests.slice(1)).toEqual([ - { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low", batch } }, - { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid", batch } }, + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low" } }, + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid", batch: "server-2" } }, ]); expect(container.textContent).toContain("Applied 2 of 2 proposals."); expect(button("Apply all").disabled).toBe(true); @@ -208,7 +207,7 @@ test("Apply all merges both confirmed rows when every reload fails", async () => await click(button("Auto-assign")); reloadFails = true; await click(button("Apply all")); - expect(requests.slice(1).map(r => r.body)).toEqual([{ model: "a/small", effort: "low", batch: expect.any(String) }, { model: "a/mid", batch: expect.any(String) }]); + expect(requests.slice(1).map(r => r.body)).toEqual([{ model: "a/small", effort: "low" }, { model: "a/mid", batch: "server-2" }]); expect(roleRow("explorer").querySelectorAll("td")[1]!.textContent).toBe("a/small · low"); expect(roleRow("worker").querySelectorAll("td")[1]!.textContent).toBe("a/mid · high"); await expectEffortOptions("explorer", ["Inherit effort", "low", "medium", "high"]); @@ -267,9 +266,7 @@ for (const availability of ["absent", "throws"] as const) { await click(button("Auto-assign")); await click(button("Apply all")); const batches = requests.slice(1).map(r => (r.body as { batch?: string }).batch); - expect(batches).toHaveLength(2); - expect(batches[0]).toMatch(/^[0-9a-f-]{36}$/); - expect(batches[1]).toBe(batches[0]); + expect(batches).toEqual([undefined, "server-2"]); expect(container.textContent).toContain("Applied 2 of 2 proposals."); expect(button("Auto-assign").disabled).toBe(false); }); diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index 4148ac17e8c..0b5c8927ef2 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -82,7 +82,8 @@ beforeEach(() => { omoJsoncModel: omoWriteStatus === "written" && model !== undefined ? model : row.omoJsoncModel ?? null, } : row), }; - return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: omoWriteStatus } }); + // Like the server, a write without a batch starts one and the response names it. + return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: omoWriteStatus }, batch: body.batch ?? `server-${puts.length}` }); } if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "xai/grok-4.5"] }); if (url.endsWith("/api/injection-model")) { @@ -552,10 +553,9 @@ test("lists the role change history as one entry per batch and restores a batch' await act(async () => { restore.click(); }); await settle(); expect(puts.map(put => ({ role: put.url.slice(put.url.lastIndexOf("/") + 1), body: put.body }))).toEqual([ - { role: "librarian", body: { effort: "medium", batch: (puts[0]!.body as { batch: string }).batch, restores: "apply-all" } }, - { role: "explorer", body: { model: "gpt-5.5", effort: null, batch: (puts[0]!.body as { batch: string }).batch, restores: "apply-all" } }, + { role: "librarian", body: { effort: "medium", restores: "apply-all" } }, + { role: "explorer", body: { model: "gpt-5.5", effort: null, batch: "server-1", restores: "apply-all" } }, ]); - expect((puts[0]!.body as { batch: string }).batch).not.toBe("apply-all"); expect(container.textContent).toContain("Restored 2 role(s)"); }); @@ -601,7 +601,7 @@ for (const availability of ["absent", "throws"] as const) { await act(async () => { restore().click(); }); await settle(); expect(puts).toHaveLength(2); - expect((puts[0]!.body as { batch: string }).batch).toMatch(/^[0-9a-f-]{36}$/); + expect(puts.map(put => (put.body as { batch?: string }).batch)).toEqual([undefined, "server-1"]); expect(container.textContent).toContain("Restored 2 role(s)"); expect(restore().disabled).toBe(false); expect(restore().textContent).toBe("Restore"); diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index 889ff9922da..da253f26413 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -4,6 +4,10 @@ * role write spans `$CODEX_HOME/agents/.toml` and LazyCodex's omo.jsonc mirror. So the * values are recorded rather than bytes, and a restore is an ordinary write of the earlier values. * Writes made in one action (auto-assign's Apply all) share a batch and are restored together. + * The server hands out batch ids and a write joins a batch only while the history still holds it whole, + * so an id compaction dropped can never start over and pass for the beginning of a batch. Every row + * carries its number in its batch, which keeps the cap durable and lets a read tell a whole batch + * from a cut one, which is never listed or restored. * The file sits under the opencodex config directory, which outlives a switch of Codex home, so every * row names the canonical Codex home it was written in and only that home's rows are listed or restored. * The file is bounded three ways: a batch holds at most ROLE_HISTORY_LIMIT rows, an append that takes @@ -30,10 +34,18 @@ export interface RoleModelChange { interface StoredRoleModelChange extends RoleModelChange { readonly codexHome: string; + /** This row's number in its batch, from 1: a batch is whole when it holds rows 1 to its highest. */ + readonly n: number; /** The batch this row's write restored, so compaction keeps that batch while the Restore is kept. */ readonly restores?: string; } +/** The batch a write is recorded in and its row number there. */ +export interface BatchSlot { + readonly batch: string; + readonly n: number; +} + /** Rows one listing returns, and rows one batch may hold, so a whole batch always fits a listing. */ export const ROLE_HISTORY_LIMIT = 50; export const ROLE_HISTORY_MAX_BYTES = 256 * 1024; @@ -49,7 +61,7 @@ export function isRoleHistoryBatch(value: unknown): value is string { } /** `codexHome` is the canonical path `getCodexHome()` returns, so two spellings of one home match. */ -export function recordRoleModelChange(codexHome: string, change: RoleModelChange & { readonly restores?: string }, dir: string = integrationsDir()): void { +export function recordRoleModelChange(codexHome: string, change: RoleModelChange & { readonly n: number; readonly restores?: string }, dir: string = integrationsDir()): void { ensureDir(historyPath(dir)); const stored: StoredRoleModelChange = { ...change, codexHome }; appendFileSync(historyPath(dir), `${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); @@ -69,7 +81,7 @@ export function recordRoleModelChange(codexHome: string, change: RoleModelChange * whole, so a Restore still writing its roles never loses the batch that vouches for them. */ function compact(dir: string): void { - const rows = tailRows(historyPath(dir)); + const rows = wholeBatches(tailRows(historyPath(dir))); let newest = 0; let bytes = 0; for (const row of rows) { @@ -107,6 +119,7 @@ function parseRow(line: string): StoredRoleModelChange | null { return typeof change === "object" && change !== null && typeof change.codexHome === "string" && typeof change.at === "string" && typeof change.role === "string" && isRoleHistoryBatch(change.batch) && isSetting(change.before) && isSetting(change.after) + && Number.isSafeInteger(change.n) && change.n >= 1 && (change.restores === undefined || isRoleHistoryBatch(change.restores)) ? change : null; @@ -115,7 +128,7 @@ function parseRow(line: string): StoredRoleModelChange | null { /** * Every Codex home's rows, newest first, from at most the last ROLE_HISTORY_MAX_BYTES of the file and * at most ROLE_HISTORY_MAX_ROWS of them. When the read starts mid-file, its first partial line is - * skipped and so is the batch of its oldest row, which may continue above the read. + * skipped; a batch that continues above the read is left for `wholeBatches` to drop. */ function tailRows(path: string): StoredRoleModelChange[] { let fd: number; @@ -126,7 +139,6 @@ function tailRows(path: string): StoredRoleModelChange[] { throw error; } let text: string; - let truncated: boolean; try { const stat = fstatSync(fd); // Windows opens a directory for reading, so the type is checked rather than left to the read. @@ -140,24 +152,34 @@ function tailRows(path: string): StoredRoleModelChange[] { if (count === 0) break; length += count; } - truncated = start > 0; const read = bytes.subarray(0, length); - text = (truncated ? read.subarray(read.indexOf(10) + 1) : read).toString("utf8"); + text = (start > 0 ? read.subarray(read.indexOf(10) + 1) : read).toString("utf8"); } finally { closeSync(fd); } - const lines = text.split("\n").filter(line => line.trim()); - truncated ||= lines.length > ROLE_HISTORY_MAX_ROWS; - const rows = lines.slice(-ROLE_HISTORY_MAX_ROWS).flatMap(line => { + return text.split("\n").filter(line => line.trim()).slice(-ROLE_HISTORY_MAX_ROWS).flatMap(line => { const row = parseRow(line); return row ? [row] : []; }).reverse(); - const partial = truncated ? rows.at(-1) : undefined; - return partial ? rows.filter(row => batchKey(row) !== batchKey(partial)) : rows; +} + +/** + * The rows of every batch that holds each of its rows 1 to its highest, so a batch the read or a + * compaction cut into is never offered as if it were whole. + */ +function wholeBatches(rows: StoredRoleModelChange[]): StoredRoleModelChange[] { + const numbers = new Map>(); + const highest = new Map(); + for (const row of rows) { + const key = batchKey(row); + numbers.set(key, (numbers.get(key) ?? new Set()).add(row.n)); + highest.set(key, Math.max(highest.get(key) ?? 0, row.n)); + } + return rows.filter(row => numbers.get(batchKey(row))!.size === highest.get(batchKey(row))); } function forHome(rows: StoredRoleModelChange[], codexHome: string): RoleModelChange[] { - return rows.flatMap(({ codexHome: home, restores: _restores, ...change }) => home === codexHome ? [change] : []); + return rows.flatMap(({ codexHome: home, n: _n, restores: _restores, ...change }) => home === codexHome ? [change] : []); } /** @@ -168,7 +190,7 @@ function forHome(rows: StoredRoleModelChange[], codexHome: string): RoleModelCha * integration journal reads its own. */ export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { - const newestFirst = forHome(tailRows(historyPath(dir)), codexHome); + const newestFirst = forHome(wholeBatches(tailRows(historyPath(dir))), codexHome); const sizes = new Map(); for (const change of newestFirst) sizes.set(change.batch, (sizes.get(change.batch) ?? 0) + 1); const listed = new Set(); @@ -182,9 +204,17 @@ export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HIS return newestFirst.filter(change => listed.has(change.batch)); } -/** How many rows this Codex home's history holds in `batch`; a write is refused once it reaches the cap. */ -export function roleHistoryBatchSize(codexHome: string, batch: string, dir: string = integrationsDir()): number { - return forHome(tailRows(historyPath(dir)), codexHome).filter(change => change.batch === batch).length; +/** + * Where a write in this Codex home is recorded. Without `batch` it starts a new batch under a fresh id. + * With one, it joins that batch only while this home's retained history holds the batch whole, and is + * refused once the batch holds ROLE_HISTORY_LIMIT rows. An id compaction dropped, or one the server + * never handed out, is closed: it cannot take a row and pass for the start of a batch. + */ +export function claimBatchRow(codexHome: string, batch: string | undefined, dir: string = integrationsDir()): BatchSlot | "batch_closed" | "batch_full" { + if (batch === undefined) return { batch: crypto.randomUUID(), n: 1 }; + const held = wholeBatches(tailRows(historyPath(dir))).filter(row => row.codexHome === codexHome && row.batch === batch); + if (held.length === 0) return "batch_closed"; + return held.length >= ROLE_HISTORY_LIMIT ? "batch_full" : { batch, n: held.length + 1 }; } /** @@ -195,6 +225,6 @@ export function roleHistoryBatchSize(codexHome: string, batch: string, dir: stri * because a Restore's own rows push the batch it restores out of that listing before it finishes. */ export function restorableSetting(codexHome: string, batch: string, role: string, dir: string = integrationsDir()): RoleSetting | null { - const changes = forHome(tailRows(historyPath(dir)), codexHome).filter(change => change.batch === batch && change.role === role); + const changes = forHome(wholeBatches(tailRows(historyPath(dir))), codexHome).filter(change => change.batch === batch && change.role === role); return changes.at(-1)?.before ?? null; } diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index f4f63575600..d9d55614dce 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -120,18 +120,21 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (body.restores !== undefined && !history.isRoleHistoryBatch(body.restores)) { return jsonResponse({ error: "restores must be 1 to 64 letters, digits or dashes", code: "invalid_restores" }, 400, req, config); } - // A batch is capped at the listing size, so reusing one id cannot grow a batch the history could - // never list or restore whole. History is optional: a file that cannot be read does not block saves. - if (typeof body.batch === "string") { - let size = 0; - try { - size = history.roleHistoryBatchSize(codexHome, body.batch); - } catch (error) { - console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); + // A write without `batch` starts a batch and the response names it; a later write joins it only + // while it is this home's newest batch, up to the listing size. History is optional: a file that + // cannot be read does not block saves, and such a save is simply not recorded. + let slot: import("../../codex/role-model-history").BatchSlot | null = null; + try { + const claimed = history.claimBatchRow(codexHome, body.batch); + if (claimed === "batch_closed") { + return jsonResponse({ error: "this batch takes no more changes; leave batch out to start a new one", code: "batch_closed" }, 409, req, config); } - if (size >= history.ROLE_HISTORY_LIMIT) { + if (claimed === "batch_full") { return jsonResponse({ error: `a batch holds at most ${history.ROLE_HISTORY_LIMIT} changes`, code: "batch_full" }, 409, req, config); } + slot = claimed; + } catch (error) { + console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); } let model: string | undefined; let effort: string | null | undefined; @@ -172,16 +175,19 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis // Filesystem and ownership errors carry absolute paths and UIDs; keep them out of the response. return jsonResponse({ error: "could not write the role file", code: "write_failed" }, 500, req, config); } - if (toml.status === "written") { + let recorded: string | undefined; + if (toml.status === "written" && slot) { try { history.recordRoleModelChange(codexHome, { at: new Date().toISOString(), - batch: body.batch ?? crypto.randomUUID(), + batch: slot.batch, + n: slot.n, role, before, after: { model: model ?? before.model, effort: effort === undefined ? before.effort : effort }, ...(typeof body.restores === "string" ? { restores: body.restores } : {}), }); + recorded = slot.batch; } catch (error) { // The write already stands; a lost history row only costs the Restore button for it. console.error(`[codex-agent-roles] could not record the change: ${String(error)}`); @@ -203,5 +209,6 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis ...(effort !== undefined ? { effort } : {}), toml, omoJsonc: { status: omoStatus }, + ...(recorded ? { batch: recorded } : {}), }, 200, req, config); } diff --git a/structure/subagents.md b/structure/subagents.md index 2f68ed8edf7..ef2f5675568 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,7 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. -- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The optional body field `batch` (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) groups one action's writes; auto-assign's Apply all sends one per click, and a write without it gets its own. A batch holds at most 50 rows: a write into a batch the current home's history already holds 50 times is refused with 409 `batch_full` before the role file is touched. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: an append that takes it past 256 KiB rewrites it to its newest whole batches within half that, and a read looks at no more than the last 256 KiB and 1000 rows, skipping the oldest batch the read cut into. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's listed history holds that batch for that role with exactly those earlier values. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The server hands out batch ids: a write without the optional body field `batch` starts a new batch, and a recorded write's response names it as `batch`; a later write that sends that id (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) joins it, so auto-assign's Apply all and a Restore send the id from their first write's response with every later one. A write joins a batch only while the current home's retained history holds it whole, else 409 `batch_closed`, so an id compaction dropped, or one the server never issued, cannot start over and pass for the beginning of a batch; a batch holds at most 50 rows, else 409 `batch_full`; both are refused before the role file is touched. Each row stores its number in its batch, which keeps that cap durable and lets every read drop a batch missing any of its rows, so a cut batch is never listed or restored. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: an append that takes it past 256 KiB rewrites it to its newest whole batches within half that, plus every batch a kept row's Restore puts back, and a read looks at no more than the last 256 KiB and 1000 rows. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's retained history (every whole batch the file holds, not only the 50 listed rows) holds that batch for that role with exactly those earlier values, so a Restore's own rows cannot push the batch it restores out of reach. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index 8e028f70ecc..8cd35ae54f7 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -66,8 +66,10 @@ describe("/api/codex-agent-roles", () => { writeFileSync(join(homeB, "agents", "explorer.toml"), ROLE); installLazyCodex(homeB); const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); - expect((await write({ model: "xai/grok-4.5", batch: "home-a" })).status).toBe(200); - const restore = { model: "gpt-5.5", effort: null, batch: "restore-1", restores: "home-a" }; + const written = await write({ model: "xai/grok-4.5" }); + expect(written.status).toBe(200); + const homeABatch = written.body.batch as string; + const restore = { model: "gpt-5.5", effort: null, restores: homeABatch }; // The proxy restarts against a second home with a role of the same name. process.env.CODEX_HOME = homeB; @@ -77,25 +79,26 @@ describe("/api/codex-agent-roles", () => { process.env.CODEX_HOME = homeA; const history = (await call("/api/codex-agent-roles")).body.history as Array>; - expect(history.map(change => change.batch)).toEqual(["home-a"]); + expect(history.map(change => change.batch)).toEqual([homeABatch]); expect(history[0]).not.toHaveProperty("codexHome"); + expect(history[0]).not.toHaveProperty("n"); expect(await write({ ...restore, model: "a/other" })).toMatchObject({ status: 409, body: { code: "restore_not_in_history" } }); expect((await write(restore)).status).toBe(200); expect(readFileSync(join(homeA, "agents", "explorer.toml"), "utf8")).toBe(ROLE); }); - function historyRow(batch: string, role: string, model = "m"): import("../../src/codex/role-model-history").RoleModelChange { - return { at: "2026-10-24T00:00:00.000Z", batch, role, before: { model: null, effort: null }, after: { model, effort: null } }; + function historyRow(batch: string, role: string, model = "m", n = 1): import("../../src/codex/role-model-history").RoleModelChange & { n: number } { + return { at: "2026-10-24T00:00:00.000Z", batch, n, role, before: { model: null, effort: null }, after: { model, effort: null } }; } test("lists whole batches and never more than 50 rows, even when one batch id was reused past its cap", async () => { const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); const dir = join(root, "history"); - for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("a", `a-${index}`), dir); - for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("b", `b-${index}`), dir); + for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("a", `a-${index}`, "m", index + 1), dir); + for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("b", `b-${index}`, "m", index + 1), dir); expect(listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir).map(change => change.batch)).toEqual(Array(30).fill("b")); // A file written before the batch cap can hold one batch larger than the whole response. - for (let index = 0; index < 120; index++) recordRoleModelChange("home", historyRow("reused", `r-${index}`), dir); + for (let index = 0; index < 120; index++) recordRoleModelChange("home", historyRow("reused", `r-${index}`, "m", index + 1), dir); const history = listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir); expect(history.length).toBeLessThanOrEqual(ROLE_HISTORY_LIMIT); expect(history.map(change => change.batch)).toEqual(Array(30).fill("b")); @@ -104,19 +107,24 @@ describe("/api/codex-agent-roles", () => { test("a write into a batch that already holds 50 rows is refused before the role file changes", async () => { const { recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); const dir = join(root, "ocx", "integrations"); - for (let index = 0; index < ROLE_HISTORY_LIMIT; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `role-${index}`), dir); + for (let index = 0; index < ROLE_HISTORY_LIMIT; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `role-${index}`, "m", index + 1), dir); const refused = await call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify({ model: "xai/grok-4.5", batch: "full" }) }); expect(refused).toMatchObject({ status: 409, body: { code: "batch_full" } }); expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); - expect((await call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify({ model: "xai/grok-4.5", batch: "next" }) })).status).toBe(200); + expect((await put("explorer", "xai/grok-4.5")).status).toBe(200); }); + /** The batch the server recorded for the newest write. */ + async function newestBatch(): Promise { + return ((await call("/api/codex-agent-roles")).body.history as Array<{ batch: string }>)[0]!.batch; + } + // explorer pinned gpt-5.5 with no effort; librarian inherited its model and held effort high. - async function seedRestorable(dir: string, batch: string, explorerAfter: string, librarianAfter: string): Promise { + async function seedRestorable(dir: string, batch: string, explorerAfter: string, librarianAfter: string, start = 1): Promise { const { recordRoleModelChange } = await import("../../src/codex/role-model-history"); const home = join(root, "codex"); - recordRoleModelChange(home, { ...historyRow(batch, "explorer", explorerAfter), before: { model: "gpt-5.5", effort: null } }, dir); - recordRoleModelChange(home, { ...historyRow(batch, "librarian", librarianAfter), before: { model: null, effort: "high" } }, dir); + recordRoleModelChange(home, { ...historyRow(batch, "explorer", explorerAfter, start), before: { model: "gpt-5.5", effort: null } }, dir); + recordRoleModelChange(home, { ...historyRow(batch, "librarian", librarianAfter, start + 1), before: { model: null, effort: "high" } }, dir); } test("a Restore of a full 50-row batch writes every role, though its own rows push that batch out of the listing", async () => { @@ -124,8 +132,8 @@ describe("/api/codex-agent-roles", () => { const dir = join(root, "ocx", "integrations"); inheritingRole(`model_reasoning_effort = "low"\n${INHERITING}`); writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE.replace('model = "gpt-5.5"', 'model = "a/now"')); - for (let index = 0; index < ROLE_HISTORY_LIMIT - 2; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `gone-${index}`), dir); - await seedRestorable(dir, "full", "a/now", "a/now"); + for (let index = 0; index < ROLE_HISTORY_LIMIT - 2; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `gone-${index}`, "m", index + 1), dir); + await seedRestorable(dir, "full", "a/now", "a/now", ROLE_HISTORY_LIMIT - 1); expect(listRoleModelChanges(join(root, "codex"), ROLE_HISTORY_LIMIT, dir)).toHaveLength(ROLE_HISTORY_LIMIT); const restore = (role: string, body: Record) => roleCall(role, { ...body, restores: "full" }); expect((await restore("explorer", { model: "gpt-5.5", effort: null })).status).toBe(200); @@ -154,13 +162,72 @@ describe("/api/codex-agent-roles", () => { expect(readFileSync(join(root, "codex", "agents", "librarian.toml"), "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); }); + test("120 writes into the batch the server handed out stop at 50 and never lose the batch's first value", async () => { + const { ROLE_HISTORY_LIMIT, restorableSetting } = await import("../../src/codex/role-model-history"); + const wide = (index: number) => `m${index}-${"w".repeat(4000)}`; + const started = await roleCall("explorer", { model: wide(0) }); + expect(started.status).toBe(200); + const batch = await newestBatch(); + let accepted = 1; + const refusals = new Set(); + for (let index = 1; index < 120; index++) { + const result = await roleCall("explorer", { model: wide(index), batch }); + if (result.status === 200) accepted += 1; + else refusals.add(`${result.status} ${String(result.body.code)}`); + } + expect(accepted).toBeLessThanOrEqual(ROLE_HISTORY_LIMIT); + expect([...refusals].every(refusal => refusal === "409 batch_full" || refusal === "409 batch_closed")).toBe(true); + // Wide rows close the batch early by compaction; either way only a whole batch is listed or restored. + const listed = ((await call("/api/codex-agent-roles")).body.history as Array<{ batch: string }>).filter(change => change.batch === batch); + expect([0, accepted]).toContain(listed.length); + expect([null, "gpt-5.5"]).toContain(restorableSetting(join(root, "codex"), batch as string, "explorer")?.model ?? null); + }, 60_000); + + test("a batch id compaction dropped, or one the server never handed out, takes no rows, so Restore never offers a value from inside a batch", async () => { + const { recordRoleModelChange, restorableSetting } = await import("../../src/codex/role-model-history"); + const first = await roleCall("explorer", { model: "middle" }); + expect(first.status).toBe(200); + const batch = await newestBatch(); + // Wide writes in other batches compact the file past that first batch. + const dir = join(root, "ocx", "integrations"); + for (let index = 0; index < 80; index++) recordRoleModelChange(join(root, "codex"), historyRow(`other-${index}`, "librarian", "w".repeat(4000)), dir); + expect(restorableSetting(join(root, "codex"), batch, "explorer")).toBeNull(); + expect(await roleCall("explorer", { model: "new", batch })).toMatchObject({ status: 409, body: { code: "batch_closed" } }); + expect(await roleCall("explorer", { model: "new", batch: "client-made" })).toMatchObject({ status: 409, body: { code: "batch_closed" } }); + const history = (await call("/api/codex-agent-roles")).body.history as Array<{ role: string }>; + expect(history.filter(change => change.role === "explorer")).toEqual([]); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toContain('model = "middle"'); + }); + + test("a batch reused after another batch wrote keeps its first value as the one Restore offers", async () => { + // An interleaved fragment: rows 2 and 3 of a batch whose row 1 (the original value) is gone. + const { listRoleModelChanges, recordRoleModelChange, restorableSetting } = await import("../../src/codex/role-model-history"); + const dir = join(root, "fragment"); + recordRoleModelChange("home", { ...historyRow("cut", "explorer", "late", 2), before: { model: "middle", effort: null } }, dir); + recordRoleModelChange("home", historyRow("whole", "worker", "m", 1), dir); + recordRoleModelChange("home", { ...historyRow("cut", "explorer", "latest", 3), before: { model: "late", effort: null } }, dir); + expect(listRoleModelChanges("home", 50, dir).map(change => change.batch)).toEqual(["whole"]); + expect(restorableSetting("home", "cut", "explorer", dir)).toBeNull(); + + inheritingRole(); + expect((await roleCall("explorer", { model: "middle" })).status).toBe(200); + const original = await newestBatch(); + expect((await roleCall("librarian", { effort: "low" })).status).toBe(200); + expect(await newestBatch()).not.toBe(original); + expect(await roleCall("explorer", { model: "new", batch: original })).toMatchObject({ status: 200, body: { batch: original } }); + const history = (await call("/api/codex-agent-roles")).body.history as Array<{ role: string; before: { model: string | null } }>; + expect(history.filter(change => change.role === "explorer").map(change => change.before.model)).toEqual(["middle", "gpt-5.5"]); + expect((await roleCall("explorer", { model: "middle", effort: null, restores: original })).status).toBe(409); + expect((await roleCall("explorer", { model: "gpt-5.5", effort: null, restores: original })).status).toBe(200); + }); + test("keeps the history file within its byte budget by dropping the oldest whole batches", async () => { const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); const dir = join(root, "history"); const wide = "m".repeat(4000); let batches = 0; for (let written = 0; written < ROLE_HISTORY_MAX_BYTES * 3; written += 3 * wide.length, batches += 1) { - for (let role = 0; role < 3; role++) recordRoleModelChange("home", historyRow(`batch-${batches}`, `role-${role}`, wide), dir); + for (let role = 0; role < 3; role++) recordRoleModelChange("home", historyRow(`batch-${batches}`, `role-${role}`, wide, role + 1), dir); } const path = join(dir, "codex-agent-roles.jsonl"); expect(fs.statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); @@ -221,13 +288,18 @@ describe("/api/codex-agent-roles", () => { test("records each written change with its earlier values, newest first, and skips an unchanged save", async () => { const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); - expect((await write({ model: "xai/grok-4.5", effort: "high", batch: "apply-all-1" })).status).toBe(200); - expect((await write({ model: "xai/grok-4.5", effort: "high", batch: "apply-all-2" })).body).toMatchObject({ toml: { status: "unchanged" } }); + const first = await write({ model: "xai/grok-4.5", effort: "high" }); + expect(first.status).toBe(200); + const batch = first.body.batch as string; + expect(batch).toMatch(/^[0-9a-f-]{36}$/); + const unchanged = await write({ model: "xai/grok-4.5", effort: "high", batch }); + expect(unchanged.body).toMatchObject({ toml: { status: "unchanged" } }); + expect(unchanged.body).not.toHaveProperty("batch"); await write({ effort: null }); const history = (await call("/api/codex-agent-roles")).body.history as Record[]; expect(history.map(({ batch, role, before, after }) => ({ batch, role, before, after }))).toEqual([ { batch: expect.stringMatching(/^[0-9a-f-]{36}$/), role: "explorer", before: { model: "xai/grok-4.5", effort: "high" }, after: { model: "xai/grok-4.5", effort: null } }, - { batch: "apply-all-1", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "high" } }, + { batch, role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "high" } }, ]); const refused = await write({ model: "gpt-5.5", batch: "../x" }); expect(refused).toMatchObject({ status: 400, body: { code: "invalid_batch" } }); @@ -244,7 +316,7 @@ describe("/api/codex-agent-roles", () => { }); const saved = await put("explorer", "xai/grok-4.5"); expect(saved.status).toBe(200); - expect(saved.body).toEqual({ ok: true, role: "explorer", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(saved.body).toEqual({ ok: true, role: "explorer", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE.replace('model = "gpt-5.5"', 'model = "xai/grok-4.5"')); expect((await call("/api/codex-agent-roles")).body.roles).toEqual([{ role: "explorer", model: "xai/grok-4.5", effort: null, omoJsoncModel: "xai/grok-4.5" }]); }); @@ -324,7 +396,7 @@ describe("/api/codex-agent-roles", () => { writeFileSync(omoPath, '{ "[codex]": { "agents": { "librarian": { "model": "omo/pick", "temperature": 0.2 } } } }\n'); const set = await roleCall("librarian", { effort: "high" }); expect(set.status).toBe(200); - expect(set.body).toEqual({ ok: true, role: "librarian", effort: "high", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(set.body).toEqual({ ok: true, role: "librarian", effort: "high", toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(rolePath, "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); expect(JSON.parse(readFileSync(omoPath, "utf8"))["[codex]"].agents.librarian).toEqual({ model: "omo/pick", temperature: 0.2, reasoning: "high" }); const listed = (await call("/api/codex-agent-roles")).body.roles as Array>; @@ -339,7 +411,7 @@ describe("/api/codex-agent-roles", () => { const cleared = await roleCall("librarian", { effort: null }); expect(cleared.status).toBe(200); - expect(cleared.body).toEqual({ ok: true, role: "librarian", effort: null, toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(cleared.body).toEqual({ ok: true, role: "librarian", effort: null, toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(rolePath, "utf8")).toBe(INHERITING); expect(JSON.parse(readFileSync(omoPath, "utf8"))["[codex]"].agents.librarian).toEqual({ model: "omo/pick", temperature: 0.2 }); }); @@ -384,7 +456,7 @@ describe("/api/codex-agent-roles", () => { const omoPath = join(root, "home", ".omo", "omo.jsonc"); writeFileSync(omoPath, '{ "[codex]": { "agents": { "librarian": { "reasoning": "high" } } } }\n'); const saved = await put("librarian", "xai/grok-4.5"); - expect(saved.body).toEqual({ ok: true, role: "librarian", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(saved.body).toEqual({ ok: true, role: "librarian", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(rolePath, "utf8")).toBe(`model = "xai/grok-4.5"\nmodel_reasoning_effort = "high"\n${INHERITING}`); expect(JSON.parse(readFileSync(omoPath, "utf8"))["[codex]"].agents.librarian).toEqual({ model: "xai/grok-4.5", reasoning: "high" }); }); From 36172ec3efc59e96200e39bb0102c72d33adec72 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:14:01 +0900 Subject: [PATCH 15/16] fix(codex): retain restore sources before history compaction --- .../src/content/docs/guides/integrations.md | 2 +- scripts/test-layout/layout.json | 1 + src/codex/role-model-history.ts | 62 ++++++++----- structure/subagents.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../codex-role-history-recovery.test.ts | 89 +++++++++++++++++++ 6 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 tests/server/codex-role-history-recovery.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 7e5fd2005ad..4e8399fdb6b 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,7 +912,7 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` -Under the table, **Change history** lists the newest changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, in whole batches and never more than 50, so an Apply all is listed with every role it wrote or not at all, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory, which keeps itself under 256 KiB by dropping its oldest whole batches; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. +Under the table, **Change history** lists the newest changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, in whole batches and never more than 50, so an Apply all is listed with every role it wrote or not at all, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory, which retains at most 256 KiB and 1000 rows by dropping its oldest whole batches. Compaction targets 128 KiB and preserves a Restore together with its complete source batch when they fit both limits, making room by dropping unrelated older batches; history can still expire as later writes arrive. The writer captures the bounded history before appending so a Restore does not evict its own source at the retention boundary; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. ## Kilo diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 925eded22c0..50693bc3b50 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1802,6 +1802,7 @@ "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", "codex-agent-role-routes.test.ts": "server", + "codex-role-history-recovery.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index da253f26413..9c8c659e2cb 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -64,10 +64,12 @@ export function isRoleHistoryBatch(value: unknown): value is string { export function recordRoleModelChange(codexHome: string, change: RoleModelChange & { readonly n: number; readonly restores?: string }, dir: string = integrationsDir()): void { ensureDir(historyPath(dir)); const stored: StoredRoleModelChange = { ...change, codexHome }; + // Capture the complete source before this append can move its first row outside either tail limit. + const tail = tailRows(historyPath(dir)); appendFileSync(historyPath(dir), `${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); - if (statSync(historyPath(dir)).size <= ROLE_HISTORY_MAX_BYTES) return; + if (statSync(historyPath(dir)).size <= ROLE_HISTORY_MAX_BYTES && tail.lines < ROLE_HISTORY_MAX_ROWS) return; try { - compact(dir); + compact(dir, wholeBatches([stored, ...tail.rows])); } catch (cause) { // Reads stay bounded without the trim; the next append retries it. throw new Error("recorded the change but could not trim the history file", { cause }); @@ -78,21 +80,39 @@ export function recordRoleModelChange(codexHome: string, change: RoleModelChange * Rewrite the file to its newest rows within half the byte budget and the row budget, so the next * compaction is many saves away. A batch the cut falls inside is dropped whole, for every Codex home, * so no home is left with part of an Apply all to restore. A kept Restore keeps the batch it restores - * whole, so a Restore still writing its roles never loses the batch that vouches for them. + * whole when the Restore and its sources fit both read limits; protected rows displace older batches. */ -function compact(dir: string): void { - const rows = wholeBatches(tailRows(historyPath(dir))); - let newest = 0; - let bytes = 0; +function compact(dir: string, rows: StoredRoleModelChange[]): void { + const batches = new Map(); for (const row of rows) { - bytes += Buffer.byteLength(JSON.stringify(row)) + 1; - if (bytes > ROLE_HISTORY_MAX_BYTES / 2 || newest >= ROLE_HISTORY_MAX_ROWS) break; - newest += 1; + const key = batchKey(row); + batches.set(key, [...(batches.get(key) ?? []), row]); + } + const keep = new Set(); + let bytes = 0; + let count = 0; + for (const key of batches.keys()) { + if (keep.has(key)) continue; + const needed = new Set([key]); + // Include source dependencies before admitting a batch, so they share its byte and row budgets. + for (const dependency of needed) { + for (const row of batches.get(dependency) ?? []) { + if (row.restores) { + const source = batchKey({ ...row, batch: row.restores }); + if (batches.has(source) && !keep.has(source)) needed.add(source); + } + } + } + const added = rows.filter(row => needed.has(batchKey(row))); + const addedBytes = added.reduce((sum, row) => sum + Buffer.byteLength(JSON.stringify(row)) + 1, 0); + if (bytes + addedBytes > ROLE_HISTORY_MAX_BYTES || count + added.length > ROLE_HISTORY_MAX_ROWS) break; + // Ordinary batches stay within the half-budget target; Restore sources may use the headroom. + if (bytes + addedBytes > ROLE_HISTORY_MAX_BYTES / 2 && !added.some(row => row.restores)) break; + for (const dependency of needed) keep.add(dependency); + bytes += addedBytes; + count += added.length; + if (bytes >= ROLE_HISTORY_MAX_BYTES / 2) break; } - const cut = new Set(rows.slice(newest).map(batchKey)); - const keep = new Set(rows.slice(0, newest).map(batchKey).filter(key => !cut.has(key))); - const sources = rows.flatMap(row => keep.has(batchKey(row)) && row.restores ? [batchKey({ ...row, batch: row.restores })] : []); - for (const source of sources) keep.add(source); const whole = rows.filter(row => keep.has(batchKey(row))).reverse(); atomicWriteFile(historyPath(dir), whole.map(row => `${JSON.stringify(row)}\n`).join("")); } @@ -130,12 +150,12 @@ function parseRow(line: string): StoredRoleModelChange | null { * at most ROLE_HISTORY_MAX_ROWS of them. When the read starts mid-file, its first partial line is * skipped; a batch that continues above the read is left for `wholeBatches` to drop. */ -function tailRows(path: string): StoredRoleModelChange[] { +function tailRows(path: string): { rows: StoredRoleModelChange[]; lines: number } { let fd: number; try { fd = openSync(path, "r"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { rows: [], lines: 0 }; throw error; } let text: string; @@ -157,10 +177,12 @@ function tailRows(path: string): StoredRoleModelChange[] { } finally { closeSync(fd); } - return text.split("\n").filter(line => line.trim()).slice(-ROLE_HISTORY_MAX_ROWS).flatMap(line => { + const lines = text.split("\n").filter(line => line.trim()).slice(-ROLE_HISTORY_MAX_ROWS); + const rows = lines.flatMap(line => { const row = parseRow(line); return row ? [row] : []; }).reverse(); + return { rows, lines: lines.length }; } /** @@ -190,7 +212,7 @@ function forHome(rows: StoredRoleModelChange[], codexHome: string): RoleModelCha * integration journal reads its own. */ export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { - const newestFirst = forHome(wholeBatches(tailRows(historyPath(dir))), codexHome); + const newestFirst = forHome(wholeBatches(tailRows(historyPath(dir)).rows), codexHome); const sizes = new Map(); for (const change of newestFirst) sizes.set(change.batch, (sizes.get(change.batch) ?? 0) + 1); const listed = new Set(); @@ -212,7 +234,7 @@ export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HIS */ export function claimBatchRow(codexHome: string, batch: string | undefined, dir: string = integrationsDir()): BatchSlot | "batch_closed" | "batch_full" { if (batch === undefined) return { batch: crypto.randomUUID(), n: 1 }; - const held = wholeBatches(tailRows(historyPath(dir))).filter(row => row.codexHome === codexHome && row.batch === batch); + const held = wholeBatches(tailRows(historyPath(dir)).rows).filter(row => row.codexHome === codexHome && row.batch === batch); if (held.length === 0) return "batch_closed"; return held.length >= ROLE_HISTORY_LIMIT ? "batch_full" : { batch, n: held.length + 1 }; } @@ -225,6 +247,6 @@ export function claimBatchRow(codexHome: string, batch: string | undefined, dir: * because a Restore's own rows push the batch it restores out of that listing before it finishes. */ export function restorableSetting(codexHome: string, batch: string, role: string, dir: string = integrationsDir()): RoleSetting | null { - const changes = forHome(wholeBatches(tailRows(historyPath(dir))), codexHome).filter(change => change.batch === batch && change.role === role); + const changes = forHome(wholeBatches(tailRows(historyPath(dir)).rows), codexHome).filter(change => change.batch === batch && change.role === role); return changes.at(-1)?.before ?? null; } diff --git a/structure/subagents.md b/structure/subagents.md index ef2f5675568..4f44c655912 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,7 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. -- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The server hands out batch ids: a write without the optional body field `batch` starts a new batch, and a recorded write's response names it as `batch`; a later write that sends that id (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) joins it, so auto-assign's Apply all and a Restore send the id from their first write's response with every later one. A write joins a batch only while the current home's retained history holds it whole, else 409 `batch_closed`, so an id compaction dropped, or one the server never issued, cannot start over and pass for the beginning of a batch; a batch holds at most 50 rows, else 409 `batch_full`; both are refused before the role file is touched. Each row stores its number in its batch, which keeps that cap durable and lets every read drop a batch missing any of its rows, so a cut batch is never listed or restored. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: an append that takes it past 256 KiB rewrites it to its newest whole batches within half that, plus every batch a kept row's Restore puts back, and a read looks at no more than the last 256 KiB and 1000 rows. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's retained history (every whole batch the file holds, not only the 50 listed rows) holds that batch for that role with exactly those earlier values, so a Restore's own rows cannot push the batch it restores out of reach. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The server hands out batch ids: a write without the optional body field `batch` starts a new batch, and a recorded write's response names it as `batch`; a later write that sends that id (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) joins it, so auto-assign's Apply all and a Restore send the id from their first write's response with every later one. A write joins a batch only while the current home's retained history holds it whole, else 409 `batch_closed`, so an id compaction dropped, or one the server never issued, cannot start over and pass for the beginning of a batch; a batch holds at most 50 rows, else 409 `batch_full`; both are refused before the role file is touched. Each row stores its number in its batch, which keeps that cap durable and lets every read drop a batch missing any of its rows, so a cut batch is never listed or restored. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: before appending, the writer captures the last 256 KiB and 1000 rows. An append that crosses either limit compacts that snapshot plus the new row to the newest whole batches, targeting 128 KiB. A kept Restore and its complete source batches are budgeted together, displacing unrelated older batches and using up to 256 KiB and 1000 rows in total when needed. If that group cannot fit both limits, it expires rather than exceeding the bounded read window. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's retained history (every whole batch the file holds, not only the 50 listed rows) holds that batch for that role with exactly those earlier values, so leaving the 50-row listing alone does not invalidate a Restore. Compaction preserves a retained Restore's source from the pre-append snapshot while their combined rows fit both retention limits; this is bounded history, not an indefinite reservation against later writes. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4d2be2a28ba..c9078b37349 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1262,6 +1262,7 @@ "codex-agent-role-models.test.ts": "routing", "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", "omo-role-models.test.ts": "clients", "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", + "codex-role-history-recovery.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations-unattended.test.ts": "clients", "sync-client-integrations.test.ts": "clients", "synced-state-location.test.ts": "lib", diff --git a/tests/server/codex-role-history-recovery.test.ts b/tests/server/codex-role-history-recovery.test.ts new file mode 100644 index 00000000000..5a9aa858c41 --- /dev/null +++ b/tests/server/codex-role-history-recovery.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listRoleModelChanges, ROLE_HISTORY_MAX_BYTES, ROLE_HISTORY_MAX_ROWS } from "../../src/codex/role-model-history"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +const config = { port: 10100, providers: {}, defaultProvider: "openai" } as unknown as OcxConfig; +let root: string, home: string, dir: string, path: string; +const setting = (model: string) => ({ model, effort: null }); +const line = (row: object) => `${JSON.stringify(row)}\n`; +const row = (batch: string, n = 1, model = "f") => ({ at: "2026-10-27T00:00:00.000Z", batch, n, codexHome: home, role: "explorer", before: setting("initial"), after: setting(model) }); + +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-role-history-recovery-"))); + home = join(root, "codex"); + dir = join(root, "ocx", "integrations"); + path = join(dir, "codex-agent-roles.jsonl"); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(home, "agents"), { recursive: true }); + const plugin = join(home, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); + writeFileSync(join(home, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); + for (const role of ["explorer", "librarian"]) writeFileSync(join(home, "agents", `${role}.toml`), 'model = "initial"\n'); + process.env.CODEX_HOME = home; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.HOME = process.env.USERPROFILE = join(root, "user"); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(root); +}); + +async function put(role: string, body: object) { + const url = new URL(`http://localhost/api/codex-agent-roles/${role}`); + const response = (await handleManagementAPI(new ManagementRequest(url, { method: "PUT", body: JSON.stringify(body) }), url, config))!; + return { status: response.status, body: await response.json() as { batch: string } }; +} + +test.each(["byte", "row"] as const)("Restore retains its interleaved source at the %s tail boundary", async boundary => { + const started = await put("explorer", { model: "changed" }); + expect(started.status).toBe(200); + const source = started.body.batch; + let text = readFileSync(path, "utf8"); + const last = line({ ...row(source, 2, "changed"), role: "librarian" }); + if (boundary === "byte") { + let index = 0; + while (Buffer.byteLength(text + last) < ROLE_HISTORY_MAX_BYTES - 1500) text += line(row(`filler-${index++}`, 1, "f".repeat(200))); + const pad = row("pad", 1, ""); + pad.after.model = "p".repeat(ROLE_HISTORY_MAX_BYTES - 64 - Buffer.byteLength(text + last + line(pad))); + text += line(pad); + } else { + for (let index = 0; index < ROLE_HISTORY_MAX_ROWS - 2; index++) text += line({ ...row(`filler-${index}`), codexHome: "other" }); + } + writeFileSync(path, text); + expect((await put("librarian", { model: "changed", batch: source })).status).toBe(200); + if (boundary === "byte") expect(statSync(path).size).toBe(ROLE_HISTORY_MAX_BYTES - 64); + expect(listRoleModelChanges(home, 50, dir).filter(change => change.batch === source)).toHaveLength(2); + const first = await put("explorer", { model: "initial", effort: null, restores: source }); + expect(first.status).toBe(200); + expect(statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); + expect(readFileSync(path, "utf8").trim().split("\n").length).toBeLessThanOrEqual(ROLE_HISTORY_MAX_ROWS); + expect((await put("librarian", { model: "initial", effort: null, restores: source, batch: first.body.batch })).status).toBe(200); + expect(readFileSync(join(home, "agents", "librarian.toml"), "utf8")).toContain('model = "initial"'); +}); + +test("protected source bytes displace unrelated batches within the read budget", async () => { + const started = await put("explorer", { model: "changed" }); + const source = started.body.batch; + const sourceRows = Array.from({ length: 50 }, (_, index) => line(row(source, index + 1, "s".repeat(3500)))).join(""); + let text = sourceRows; + let index = 0; + while (Buffer.byteLength(text) < ROLE_HISTORY_MAX_BYTES - 12000) text += line(row(`filler-${index++}`, 1, "f".repeat(500))); + writeFileSync(path, text); + writeFileSync(join(home, "agents", "explorer.toml"), `model = "${"c".repeat(15000)}"\n`); + expect((await put("explorer", { model: "initial", effort: null, restores: source })).status).toBe(200); + expect(statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); + expect(listRoleModelChanges(home, 1000, dir).filter(change => change.batch === source)).toHaveLength(50); +}); + From a99745788f4f27e72e6903d2316e30a860972eb5 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:16:16 +0900 Subject: [PATCH 16/16] fix(codex): separate history records from interrupted suffixes Co-authored-by: LilMGenius --- scripts/test-layout/layout.json | 3 +-- src/codex/role-model-history.ts | 9 +++++---- structure/subagents.md | 2 +- tests/server/codex-role-history-recovery.test.ts | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 50693bc3b50..5bbacd07f38 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1801,8 +1801,7 @@ "codex-agent-role-models.test.ts": "routing", "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", - "codex-agent-role-routes.test.ts": "server", - "codex-role-history-recovery.test.ts": "server", + "codex-agent-role-routes.test.ts": "server", "codex-role-history-recovery.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts index 9c8c659e2cb..14353361709 100644 --- a/src/codex/role-model-history.ts +++ b/src/codex/role-model-history.ts @@ -66,7 +66,8 @@ export function recordRoleModelChange(codexHome: string, change: RoleModelChange const stored: StoredRoleModelChange = { ...change, codexHome }; // Capture the complete source before this append can move its first row outside either tail limit. const tail = tailRows(historyPath(dir)); - appendFileSync(historyPath(dir), `${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); + // An interrupted JSONL suffix must not swallow the first record returned after restart. + appendFileSync(historyPath(dir), `${tail.unterminated ? "\n" : ""}${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); if (statSync(historyPath(dir)).size <= ROLE_HISTORY_MAX_BYTES && tail.lines < ROLE_HISTORY_MAX_ROWS) return; try { compact(dir, wholeBatches([stored, ...tail.rows])); @@ -150,12 +151,12 @@ function parseRow(line: string): StoredRoleModelChange | null { * at most ROLE_HISTORY_MAX_ROWS of them. When the read starts mid-file, its first partial line is * skipped; a batch that continues above the read is left for `wholeBatches` to drop. */ -function tailRows(path: string): { rows: StoredRoleModelChange[]; lines: number } { +function tailRows(path: string): { rows: StoredRoleModelChange[]; lines: number; unterminated: boolean } { let fd: number; try { fd = openSync(path, "r"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return { rows: [], lines: 0 }; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { rows: [], lines: 0, unterminated: false }; throw error; } let text: string; @@ -182,7 +183,7 @@ function tailRows(path: string): { rows: StoredRoleModelChange[]; lines: number const row = parseRow(line); return row ? [row] : []; }).reverse(); - return { rows, lines: lines.length }; + return { rows, lines: lines.length, unterminated: text.length > 0 && !text.endsWith("\n") }; } /** diff --git a/structure/subagents.md b/structure/subagents.md index 4f44c655912..55a5d7c26bb 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,7 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. -- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. The server hands out batch ids: a write without the optional body field `batch` starts a new batch, and a recorded write's response names it as `batch`; a later write that sends that id (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) joins it, so auto-assign's Apply all and a Restore send the id from their first write's response with every later one. A write joins a batch only while the current home's retained history holds it whole, else 409 `batch_closed`, so an id compaction dropped, or one the server never issued, cannot start over and pass for the beginning of a batch; a batch holds at most 50 rows, else 409 `batch_full`; both are refused before the role file is touched. Each row stores its number in its batch, which keeps that cap durable and lets every read drop a batch missing any of its rows, so a cut batch is never listed or restored. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: before appending, the writer captures the last 256 KiB and 1000 rows. An append that crosses either limit compacts that snapshot plus the new row to the newest whole batches, targeting 128 KiB. A kept Restore and its complete source batches are budgeted together, displacing unrelated older batches and using up to 256 KiB and 1000 rows in total when needed. If that group cannot fit both limits, it expires rather than exceeding the bounded read window. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's retained history (every whole batch the file holds, not only the 50 listed rows) holds that batch for that role with exactly those earlier values, so leaving the 50-row listing alone does not invalidate a Restore. Compaction preserves a retained Restore's source from the pre-append snapshot while their combined rows fit both retention limits; this is bounded history, not an indefinite reservation against later writes. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. Before appending, the writer separates an unterminated suffix with a newline: torn JSON remains skippable and a complete final row remains valid, so a restart does not swallow a newly issued batch's first row. The server hands out batch ids: a write without the optional body field `batch` starts a new batch, and a recorded write's response names it as `batch`; a later write that sends that id (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) joins it, so auto-assign's Apply all and a Restore send the id from their first write's response with every later one. A write joins a batch only while the current home's retained history holds it whole, else 409 `batch_closed`, so an id compaction dropped, or one the server never issued, cannot start over and pass for the beginning of a batch; a batch holds at most 50 rows, else 409 `batch_full`; both are refused before the role file is touched. Each row stores its number in its batch, which keeps that cap durable and lets every read drop a batch missing any of its rows, so a cut batch is never listed or restored. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: before appending, the writer captures the last 256 KiB and 1000 rows. An append that crosses either limit compacts that snapshot plus the new row to the newest whole batches, targeting 128 KiB. A kept Restore and its complete source batches are budgeted together, displacing unrelated older batches and using up to 256 KiB and 1000 rows in total when needed. If that group cannot fit both limits, it expires rather than exceeding the bounded read window. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's retained history (every whole batch the file holds, not only the 50 listed rows) holds that batch for that role with exactly those earlier values, so leaving the 50-row listing alone does not invalidate a Restore. Compaction preserves a retained Restore's source from the pre-append snapshot while their combined rows fit both retention limits; this is bounded history, not an indefinite reservation against later writes. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/server/codex-role-history-recovery.test.ts b/tests/server/codex-role-history-recovery.test.ts index 5a9aa858c41..a207d8b57d7 100644 --- a/tests/server/codex-role-history-recovery.test.ts +++ b/tests/server/codex-role-history-recovery.test.ts @@ -87,3 +87,17 @@ test("protected source bytes displace unrelated batches within the read budget", expect(listRoleModelChanges(home, 1000, dir).filter(change => change.batch === source)).toHaveLength(50); }); +test.each(["torn", "complete"])("restart after an unterminated %s suffix leaves a new Apply all batch joinable", async suffix => { + const old = await put("explorer", { model: "before-restart" }); + const complete = readFileSync(path, "utf8"); + // Disk state left by an interrupted process; no in-memory batch state survives a restart. + writeFileSync(path, suffix === "torn" ? `${complete}{"at":"interrupted` : complete.trimEnd()); + const first = await put("explorer", { model: "apply-a" }); + expect(first.status).toBe(200); + expect(typeof first.body.batch).toBe("string"); + expect((await put("librarian", { model: "apply-b", batch: first.body.batch })).status).toBe(200); + const history = listRoleModelChanges(home, 50, dir); + expect(history.filter(change => change.batch === first.body.batch)).toHaveLength(2); + expect(history.filter(change => change.batch === old.body.batch)).toHaveLength(1); + expect(readFileSync(join(home, "agents", "librarian.toml"), "utf8")).toContain('model = "apply-b"'); +});