diff --git a/src/components/sidebar/SidebarThreadTree.vue b/src/components/sidebar/SidebarThreadTree.vue index d37a09dc3..8c5398ee4 100644 --- a/src/components/sidebar/SidebarThreadTree.vue +++ b/src/components/sidebar/SidebarThreadTree.vue @@ -1367,7 +1367,7 @@ onMounted(async () => { automationByThreadId.value = {} } try { - automationByProjectName.value = await getProjectAutomationMap() + await reloadProjectAutomations() } catch { automationByProjectName.value = {} } @@ -2019,6 +2019,19 @@ function removeAutomationForProject( return next.length > 0 ? { ...state, [projectName]: next } : omitAutomationProject(state, projectName) } +async function reloadProjectAutomations(): Promise { + automationByProjectName.value = await getProjectAutomationMap() +} + +async function tryReloadProjectAutomations(): Promise { + try { + await reloadProjectAutomations() + return true + } catch { + return false + } +} + async function submitAutomationDialog(): Promise { let threadId = automationDialogThreadId.value let projectName = automationDialogProjectName.value @@ -2060,6 +2073,7 @@ async function submitAutomationDialog(): Promise { : await upsertThreadAutomation({ ...input, threadId }) if (automationDialogScope.value === 'project') { automationByProjectName.value = updateAutomationForProject(automationByProjectName.value, projectName, saved) + void tryReloadProjectAutomations() } else { automationByThreadId.value = updateAutomationForThread(automationByThreadId.value, threadId, saved) } @@ -2087,6 +2101,7 @@ async function onDeleteAutomationFromDialog(): Promise { if (automationDialogScope.value === 'project') { await deleteProjectAutomation(projectName, automationId) automationByProjectName.value = removeAutomationForProject(automationByProjectName.value, projectName, automationId) + void tryReloadProjectAutomations() } else { await deleteThreadAutomation(threadId, automationId) automationByThreadId.value = removeAutomationForThread(automationByThreadId.value, threadId, automationId) @@ -2272,8 +2287,13 @@ function onRemoveProject(projectName: string): void { const projectCwd = getProjectAutomationKey(projectName) emit('remove-project', projectName) if (projectCwd && projectHasAutomation(projectName)) { - void deleteProjectAutomation(projectCwd).catch(() => undefined) automationByProjectName.value = omitAutomationProject(automationByProjectName.value, projectCwd) + void deleteProjectAutomation(projectCwd) + .then(() => tryReloadProjectAutomations()) + .catch(() => tryReloadProjectAutomations()) + .finally(() => { + emit('automations-changed') + }) } closeProjectMenu() } diff --git a/src/server/codexAppServerBridge.inlinePayload.test.ts b/src/server/codexAppServerBridge.inlinePayload.test.ts index baaacf57b..b913843bd 100644 --- a/src/server/codexAppServerBridge.inlinePayload.test.ts +++ b/src/server/codexAppServerBridge.inlinePayload.test.ts @@ -1,6 +1,12 @@ import { existsSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' -import { BackendQueueProcessor, mergeSessionSkillInputsIntoTurns, sanitizeThreadTurnsInlinePayloads } from './codexAppServerBridge' +import { + BackendQueueProcessor, + mergeSessionSkillInputsIntoTurns, + parseAutomationToml, + sanitizeThreadTurnsInlinePayloads, + toAutomationApiRecord, +} from './codexAppServerBridge' const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' const pngDataUrl = `data:image/png;base64,${pngBase64}` @@ -370,3 +376,44 @@ describe('backend queue scheduling', () => { processor.dispose() }) }) + +describe('automation TOML handling', () => { + it('parses TOML string arrays without requiring JSON-only syntax', () => { + const automation = parseAutomationToml([ + 'version = 1', + 'id = "cron-smoke"', + 'kind = "cron"', + 'name = "Cron Smoke"', + 'prompt = "run"', + 'status = "ACTIVE"', + 'rrule = "FREQ=DAILY"', + "cwds = ['/tmp/project-one', '/tmp/project,two']", + 'created_at = 111', + 'updated_at = 222', + '[scheduler]', + 'execution_environment = "local"', + ].join('\n')) + + expect(automation?.cwds).toEqual(['/tmp/project-one', '/tmp/project,two']) + expect(automation?.createdAtMs).toBe(111) + expect(automation?.extraTomlLines).toContain('[scheduler]') + }) + + it('omits preserved TOML internals from automation API records', () => { + const automation = parseAutomationToml([ + 'version = 1', + 'id = "cron-smoke"', + 'kind = "cron"', + 'name = "Cron Smoke"', + 'prompt = "run"', + 'status = "ACTIVE"', + 'rrule = "FREQ=DAILY"', + 'cwds = ["/tmp/project-one"]', + '[scheduler]', + 'execution_environment = "local"', + ].join('\n')) + + expect(automation).toBeTruthy() + expect(toAutomationApiRecord(automation as NonNullable)).not.toHaveProperty('extraTomlLines') + }) +}) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 0dde66bba..f84e3043b 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -3275,21 +3275,60 @@ function serializeTomlString(value: string): string { function parseTomlStringArray(value: string): string[] { const trimmed = value.trim() if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return [] - try { - const parsed = JSON.parse(trimmed) - return Array.isArray(parsed) - ? parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) - : [] - } catch { - return [] + const values: string[] = [] + let index = 1 + const endIndex = trimmed.length - 1 + + while (index < endIndex) { + while (index < endIndex && /[\s,]/u.test(trimmed[index] ?? '')) index += 1 + if (index >= endIndex) break + + const quote = trimmed[index] + if (quote !== '"' && quote !== "'") return [] + const start = index + index += 1 + let valueText = '' + + if (quote === "'") { + const closeIndex = trimmed.indexOf("'", index) + if (closeIndex < 0 || closeIndex > endIndex) return [] + valueText = trimmed.slice(index, closeIndex) + index = closeIndex + 1 + } else { + let escaped = false + while (index < endIndex) { + const char = trimmed[index] ?? '' + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + break + } + index += 1 + } + if (index >= endIndex || trimmed[index] !== '"') return [] + try { + valueText = JSON.parse(trimmed.slice(start, index + 1)) as string + } catch { + return [] + } + index += 1 + } + + if (valueText.trim().length > 0) values.push(valueText) + while (index < endIndex && /\s/u.test(trimmed[index] ?? '')) index += 1 + if (index < endIndex && trimmed[index] !== ',') return [] } + + return values } function serializeTomlStringArray(values: string[]): string { return `[${values.map((value) => serializeTomlString(value)).join(', ')}]` } -function parseAutomationToml(raw: string): ThreadAutomationRecord | null { +export function parseAutomationToml(raw: string): ThreadAutomationRecord | null { const values: Record = {} const extraTomlLines: string[] = [] const knownKeys = new Set([ @@ -3388,6 +3427,29 @@ function serializeAutomationToml(record: ThreadAutomationRecord): string { return `${lines.join('\n')}\n` } +export function toAutomationApiRecord(record: ThreadAutomationRecord): Omit { + const { extraTomlLines: _extraTomlLines, ...apiRecord } = record + return apiRecord +} + +function toAutomationApiMap( + automationsByTarget: Record, +): Record>> { + return Object.fromEntries( + Object.entries(automationsByTarget).map(([target, automations]) => [ + target, + automations.map(toAutomationApiRecord), + ]), + ) +} + +function toAutomationApiData( + automation: ThreadAutomationRecord | ThreadAutomationRecord[] | null, +): Omit | Array> | null { + if (Array.isArray(automation)) return automation.map(toAutomationApiRecord) + return automation ? toAutomationApiRecord(automation) : null +} + function slugifyAutomationId(threadId: string, name: string): string { const preferred = name.trim().toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') if (preferred) return preferred.slice(0, 48) @@ -7255,13 +7317,13 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { if (req.method === 'GET' && url.pathname === '/codex-api/thread-automations') { const automationsByThreadId = await listThreadHeartbeatAutomations() - setJson(res, 200, { data: automationsByThreadId }) + setJson(res, 200, { data: toAutomationApiMap(automationsByThreadId) }) return } if (req.method === 'GET' && url.pathname === '/codex-api/project-automations') { const automationsByProjectName = await listProjectCronAutomations() - setJson(res, 200, { data: automationsByProjectName }) + setJson(res, 200, { data: toAutomationApiMap(automationsByProjectName) }) return } @@ -7275,7 +7337,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { const automation = automationId ? await readThreadHeartbeatAutomation(threadId, automationId) : await readThreadHeartbeatAutomations(threadId) - setJson(res, 200, { data: automation }) + setJson(res, 200, { data: toAutomationApiData(automation) }) return } @@ -7289,7 +7351,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { const automation = automationId ? await readProjectCronAutomation(projectName, automationId) : await readProjectCronAutomations(projectName) - setJson(res, 200, { data: automation }) + setJson(res, 200, { data: toAutomationApiData(automation) }) return } @@ -7357,7 +7419,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } const automation = await writeThreadHeartbeatAutomation({ threadId, id, name, prompt, rrule, status }) - setJson(res, 200, { data: automation }) + setJson(res, 200, { data: toAutomationApiRecord(automation) }) return } @@ -7378,7 +7440,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } const automation = await writeProjectCronAutomation({ projectName, id, name, prompt, rrule, status }) - setJson(res, 200, { data: automation }) + setJson(res, 200, { data: toAutomationApiRecord(automation) }) return } diff --git a/src/style.css b/src/style.css index b900cd8d7..9503ad241 100644 --- a/src/style.css +++ b/src/style.css @@ -537,6 +537,10 @@ @apply text-emerald-400; } +:root.dark .thread-row-automation-chip { + @apply bg-amber-900/40 text-amber-200 ring-1 ring-amber-700/40; +} + :root.dark .rename-thread-button { @apply text-zinc-300 hover:bg-zinc-700; } diff --git a/tests.md b/tests.md index 7c59f5356..51b8fc92e 100644 --- a/tests.md +++ b/tests.md @@ -90,6 +90,39 @@ This file tracks manual regression and feature verification steps. #### Rollback/Cleanup - Remove any test automations from the thread automation dialog or delete their folders under `$CODEX_HOME/automations//`. +### Feature: Project automations and `/automations` panel + +#### Prerequisites +- App is running from this repository. +- At least two sidebar projects have absolute workspace paths. +- Local Codex home is writable (`$CODEX_HOME` or `~/.codex`). +- Light and dark themes are both available from Settings. + +#### Steps +1. In light theme, open a project overflow menu for a project without an attached automation. +2. Confirm the menu shows `Add automation…`, then create a project automation with a name, prompt, RRULE schedule, and status. +3. Confirm the project row shows an automation chip and the same menu changes to `Manage automations…`. +4. Open `/automations` from the sidebar and confirm the new project automation appears with the visible project display name. +5. Edit the automation from `/automations`, change its name and status, save, and confirm the project row chip count and tooltip update without a full page refresh. +6. Seed or keep a cron automation record whose `cwds` contains two project paths, then edit it from one project and confirm both project rows show the updated name/status. +7. Seed a cron automation record with a TOML-style single-quoted `cwds` array such as `cwds = ['/tmp/project-one', '/tmp/project,two']`, refresh `/automations`, and confirm it is still listed. +8. Inspect `/codex-api/project-automations` for the seeded record and confirm the response includes public automation fields but not `extraTomlLines`. +9. Remove one project that has an attached automation while `/automations` is open and confirm the panel removes the deleted project row after the cleanup completes. +10. Switch to dark theme and repeat opening the project menu and `/automations`; confirm rows, chips, buttons, inputs, and empty states remain readable. + +#### Expected Results +- Project-scoped cron automations are listed under every associated `cwd`. +- Editing a multi-`cwd` project automation refreshes all affected sidebar chips/tooltips, not only the currently edited project. +- Existing TOML cron records with valid non-JSON string arrays remain visible and manageable. +- Automation API responses do not include internal preserved TOML metadata such as `extraTomlLines`. +- Removing a project deletes or detaches that project's automation association and refreshes the `/automations` panel. +- Preserved TOML metadata and table sections remain intact after saving or deleting a project automation. +- Light and dark theme project automation surfaces remain readable. + +#### Rollback/Cleanup +- Remove any test project automations from the project automation dialog or delete their folders under `$CODEX_HOME/automations//`. +- Remove temporary test projects or workspace roots created for verification. + ### Feature: Projectless new chat folders #### Prerequisites