Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/components/sidebar/SidebarThreadTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,7 @@ onMounted(async () => {
automationByThreadId.value = {}
}
try {
automationByProjectName.value = await getProjectAutomationMap()
await reloadProjectAutomations()
} catch {
automationByProjectName.value = {}
}
Expand Down Expand Up @@ -2019,6 +2019,19 @@ function removeAutomationForProject(
return next.length > 0 ? { ...state, [projectName]: next } : omitAutomationProject(state, projectName)
}

async function reloadProjectAutomations(): Promise<void> {
automationByProjectName.value = await getProjectAutomationMap()
}

async function tryReloadProjectAutomations(): Promise<boolean> {
try {
await reloadProjectAutomations()
return true
} catch {
return false
}
}

async function submitAutomationDialog(): Promise<void> {
let threadId = automationDialogThreadId.value
let projectName = automationDialogProjectName.value
Expand Down Expand Up @@ -2060,6 +2073,7 @@ async function submitAutomationDialog(): Promise<void> {
: 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)
}
Expand Down Expand Up @@ -2087,6 +2101,7 @@ async function onDeleteAutomationFromDialog(): Promise<void> {
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)
Expand Down Expand Up @@ -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')
})
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
closeProjectMenu()
}
Expand Down
49 changes: 48 additions & 1 deletion src/server/codexAppServerBridge.inlinePayload.test.ts
Original file line number Diff line number Diff line change
@@ -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}`
Expand Down Expand Up @@ -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<typeof automation>)).not.toHaveProperty('extraTomlLines')
})
})
90 changes: 76 additions & 14 deletions src/server/codexAppServerBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 3275 to +3324
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. No performance audit documented 📘 Rule violation ➹ Performance

This PR introduces behavior changes (new TOML array parsing and automation API response shaping)
without an accompanying measurement- or analysis-grounded performance audit. This risks unnoticed
regressions (e.g., payload bloat, extra requests, or slower routes) in the affected automation
flows.
Agent Prompt
## Issue description
PR Compliance ID 7 requires a performance audit for any feature/behavior change, grounded in measurements or explicit code-path analysis. This PR changes automation parsing and API response behavior, but does not include any performance-audit writeup.

## Issue Context
The changes affect automation listing/parsing and API payload shaping (potential request count/payload size/CPU parsing impacts). The audit should state what was measured (or why measurement wasn’t feasible) and include concrete results (e.g., request counts, response sizes, timing/profiler output) or a clear code-path analysis with next measurement steps.

## Fix Focus Areas
- src/server/codexAppServerBridge.ts[3275-3324]
- src/server/codexAppServerBridge.ts[3430-3451]
- src/server/codexAppServerBridge.ts[7317-7444]
- src/components/sidebar/SidebarThreadTree.vue[2267-2275]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

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<string, string> = {}
const extraTomlLines: string[] = []
const knownKeys = new Set([
Expand Down Expand Up @@ -3388,6 +3427,29 @@ function serializeAutomationToml(record: ThreadAutomationRecord): string {
return `${lines.join('\n')}\n`
}

export function toAutomationApiRecord(record: ThreadAutomationRecord): Omit<ThreadAutomationRecord, 'extraTomlLines'> {
const { extraTomlLines: _extraTomlLines, ...apiRecord } = record
return apiRecord
}

function toAutomationApiMap(
automationsByTarget: Record<string, ThreadAutomationRecord[]>,
): Record<string, Array<Omit<ThreadAutomationRecord, 'extraTomlLines'>>> {
return Object.fromEntries(
Object.entries(automationsByTarget).map(([target, automations]) => [
target,
automations.map(toAutomationApiRecord),
]),
)
}

function toAutomationApiData(
automation: ThreadAutomationRecord | ThreadAutomationRecord[] | null,
): Omit<ThreadAutomationRecord, 'extraTomlLines'> | Array<Omit<ThreadAutomationRecord, 'extraTomlLines'>> | 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)
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
33 changes: 33 additions & 0 deletions tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<automation-id>/`.

### 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/<automation-id>/`.
- Remove temporary test projects or workspace roots created for verification.

### Feature: Projectless new chat folders

#### Prerequisites
Expand Down