Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
caaab64
fix(cli): accept kebab-case workspace flags
luokerenx4 Jul 11, 2026
92019c9
fix: preserve Pi and OpenCode context state
luokerenx4 Jul 15, 2026
da5d563
Merge remote-tracking branch 'origin/dev' into codex/issue-601-contex…
luokerenx4 Jul 15, 2026
55b425b
feat: add new Workspace provider defaults
luokerenx4 Jul 15, 2026
4c89d72
feat(demo): simulate trading order pushes
luokerenx4 Jul 28, 2026
e0ff377
fix(portfolio): apply validated snapshot settings
luokerenx4 Jul 28, 2026
065f7ef
fix(ui): localize file viewer back action
luokerenx4 Jul 28, 2026
0a14cea
fix(ui): localize the market FX landing
luokerenx4 Jul 28, 2026
efe1dd4
Localize the global Issues board
luokerenx4 Jul 29, 2026
d889ec8
Recover failed Tracked detail loads
luokerenx4 Jul 29, 2026
19400c3
Localize the tradeable contracts panel
luokerenx4 Jul 29, 2026
6b7e202
Merge PR #529 as superseded by current CLI flag handling
luokerenx4 Jul 29, 2026
4c5765a
fix(ui): preserve WebPi reader scroll position
luokerenx4 Jul 29, 2026
2b8b1e0
Merge PR #608 with current model semantics preserved
luokerenx4 Jul 29, 2026
4b56a57
Merge remote-tracking branch 'origin/codex/demo-trading-push' into co…
luokerenx4 Jul 29, 2026
21dedd8
Merge remote-tracking branch 'origin/codex/snapshot-config-apply' int…
luokerenx4 Jul 29, 2026
fc590fd
Merge remote-tracking branch 'origin/codex/tracked-detail-retry' into…
luokerenx4 Jul 29, 2026
781851c
Merge remote-tracking branch 'origin/codex/localize-file-viewer-back'…
luokerenx4 Jul 29, 2026
9df37eb
Merge remote-tracking branch 'origin/codex/localize-market-fx-desk' i…
luokerenx4 Jul 29, 2026
9849a86
Merge remote-tracking branch 'origin/codex/localize-issues-board' int…
luokerenx4 Jul 29, 2026
d6bb3a7
Merge remote-tracking branch 'origin/codex/localize-tradeable-contrac…
luokerenx4 Jul 29, 2026
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
20 changes: 20 additions & 0 deletions src/core/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,26 @@ describe('writeConfigSection(trading)', () => {
})
})

describe('writeConfigSection(snapshot)', () => {
it('normalizes a valid snapshot interval before writing it', async () => {
const snapshot = await writeConfigSection('snapshot', {
enabled: true,
every: ' 2h15m ',
}) as { enabled: boolean; every: string }

expect(snapshot).toEqual({ enabled: true, every: '2h15m' })
const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string)
expect(written.every).toBe('2h15m')
})

it.each(['nonsense', '0m', ''])('rejects invalid snapshot interval %j without writing', async (every) => {
await expect(
writeConfigSection('snapshot', { enabled: true, every }),
).rejects.toThrow(/positive duration/)
expect(mockWriteFile).not.toHaveBeenCalled()
})
})

// ==================== aiProviderSchema (Zod schema validation) ====================

describe('aiProviderSchema (credential vault)', () => {
Expand Down
9 changes: 8 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { newsCollectorSchema } from '../domain/news/config.js'
import { runMigrations } from '../migrations/runner.js'
import { dataPath } from '@/core/paths.js'
import { withConfigBootstrapLock } from './config-bootstrap-lock.js'
import { parseDuration } from './duration.js'
import { isSealedEnvelope, seal, unseal } from './sealing.js'

const CONFIG_DIR = dataPath('config')
Expand Down Expand Up @@ -353,7 +354,13 @@ const portsSchema = z.object({

const snapshotSchema = z.object({
enabled: z.boolean().default(true),
every: z.string().default('15m'),
every: z.string()
.transform((value) => value.trim())
.refine(
(value) => parseDuration(value) !== null,
'Expected a positive duration such as "15m", "1h", or "2h15m"',
)
.default('15m'),
})

export const keylessDataSourceSchema = z.enum(['binance', 'okx', 'bybit'])
Expand Down
61 changes: 61 additions & 0 deletions src/migrations/0027_repair_snapshot_interval/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'

import type { MigrationContext } from '../types.js'
import { migration, repairSnapshotInterval } from './index.js'

function context(initial: unknown) {
let value = initial
const writeJson = vi.fn(async (_filename: string, next: unknown) => {
value = next
})
const ctx: MigrationContext = {
async readJson<T>(): Promise<T | undefined> {
return value as T | undefined
},
writeJson,
removeJson: vi.fn(async () => {}),
configDir: () => '/tmp/openalice-migration-test',
}
return { ctx, writeJson, value: () => value }
}

describe('0027 repair snapshot interval', () => {
it('replaces an invalid historical interval and is idempotent', async () => {
const state = context({ enabled: false, every: 'nonsense' })

await migration.up(state.ctx)
await migration.up(state.ctx)

expect(state.value()).toEqual({ enabled: false, every: '15m' })
expect(state.writeJson).toHaveBeenCalledOnce()
expect(state.writeJson).toHaveBeenCalledWith('snapshot.json', {
enabled: false,
every: '15m',
})
})

it('normalizes valid whitespace without changing other settings', () => {
expect(repairSnapshotInterval({
enabled: true,
every: ' 2h15m ',
futureSetting: true,
})).toEqual({
value: {
enabled: true,
every: '2h15m',
futureSetting: true,
},
updated: true,
})
})

it('leaves missing and already-valid intervals untouched', () => {
const current = { enabled: true, every: '30m' }
expect(repairSnapshotInterval(current)).toEqual({ value: current, updated: false })
expect(repairSnapshotInterval({ enabled: false })).toEqual({
value: { enabled: false },
updated: false,
})
expect(repairSnapshotInterval(undefined)).toEqual({ value: undefined, updated: false })
})
})
35 changes: 35 additions & 0 deletions src/migrations/0027_repair_snapshot_interval/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { parseDuration } from '../../core/duration.js'
import type { Migration } from '../types.js'

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

export function repairSnapshotInterval(raw: unknown): { value: unknown; updated: boolean } {
if (!isRecord(raw) || !Object.prototype.hasOwnProperty.call(raw, 'every')) {
return { value: raw, updated: false }
}

const every = raw.every
if (typeof every !== 'string' || parseDuration(every) === null) {
return { value: { ...raw, every: '15m' }, updated: true }
}

const normalized = every.trim()
if (normalized === every) return { value: raw, updated: false }
return { value: { ...raw, every: normalized }, updated: true }
}

export const migration: Migration = {
id: '0027_repair_snapshot_interval',
appVersion: '0.87.0-beta',
introducedAt: '2026-07-29',
affects: ['snapshot.json'],
summary: 'Repair invalid historical portfolio snapshot intervals before strict duration validation loads them.',
rationale: 'Older Portfolio UI versions persisted arbitrary strings even though the UTA snapshot pump accepts only positive h/m/s durations.',
up: async (ctx) => {
const snapshot = await ctx.readJson('snapshot.json')
const repaired = repairSnapshotInterval(snapshot)
if (repaired.updated) await ctx.writeJson('snapshot.json', repaired.value)
},
}
1 change: 1 addition & 0 deletions src/migrations/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ Each row corresponds to one migration in `src/migrations/`. The runner applies p
| `0024_pi_native_workspace_config` | 0.82.0-beta | 2026-07-18 | workspaces/workspaces/*/.pi-agent, workspaces/departed-workspaces/*/.pi-agent, Pi user agent directory | Move redirected Pi Workspace homes into Pi's native global-provider and project-settings layers. |
| `0025_retire_global_compaction_config` | 0.83.0-beta | 2026-07-19 | compaction.json, ai-provider-manager.json | Remove retired global context and compaction limits so model and native Agent runtime semantics remain authoritative. |
| `0026_agent_conversation_log` | 0.87.0-beta | 2026-07-29 | workspaces/state/agent-conversations.jsonl | Create the private append-only cross-Agent conversation event log. |
| `0027_repair_snapshot_interval` | 0.87.0-beta | 2026-07-29 | snapshot.json | Repair invalid historical portfolio snapshot intervals before strict duration validation loads them. |
4 changes: 3 additions & 1 deletion src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* deletion + Workspace pivot turned the pre-0.40 data shapes over completely, so
* pre-0.40 installs rebuild `data/` rather than migrate. The framework stays for
* future upgrades. Numbering continues FORWARD from the highest id ever shipped
* (next: 0026) — never reuse a retired id, since existing installs' journals
* (next: 0028) — never reuse a retired id, since existing installs' journals
* recorded the old ones.
*/

Expand All @@ -38,6 +38,7 @@ import { migration as migration_0023_google_native_credentials } from './0023_go
import { migration as migration_0024_pi_native_workspace_config } from './0024_pi_native_workspace_config/index.js'
import { migration as migration_0025_retire_global_compaction_config } from './0025_retire_global_compaction_config/index.js'
import { migration as migration_0026_agent_conversation_log } from './0026_agent_conversation_log/index.js'
import { migration as migration_0027_repair_snapshot_interval } from './0027_repair_snapshot_interval/index.js'

export const REGISTRY: Migration[] = [
migration_0008_disable_targetless_cron_jobs,
Expand All @@ -59,4 +60,5 @@ export const REGISTRY: Migration[] = [
migration_0024_pi_native_workspace_config,
migration_0025_retire_global_compaction_config,
migration_0026_agent_conversation_log,
migration_0027_repair_snapshot_interval,
]
5 changes: 1 addition & 4 deletions src/services/uta-supervisor/restart-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@
* 1. Atomic-write `data/control/restart-uta.flag` (write to .tmp + rename)
* with content = ISO timestamp of the request.
* 2. Guardian's fs.watch fires (debounced 100ms), Guardian SIGTERMs UTA,
* waits exit, respawns with fresh `accounts.json`.
* waits exit, and respawns it with fresh boot-time configuration.
* 3. Alice polls `${OPENALICE_UTA_URL}/__uta/health` until `startedAt` is
* newer than the pre-trigger value, or until timeout.
*
* Step 5 wires this into `trading-config.ts` so broker setup saves trigger
* UTA reload automatically. Step 4 just exposes the helper.
*/

import { writeFile, rename, mkdir } from 'fs/promises'
Expand Down
45 changes: 45 additions & 0 deletions src/webui/routes/config-snapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
writeConfigSection: vi.fn(),
triggerUTARestart: vi.fn(),
}))

vi.mock('../../core/config.js', async () => {
const actual = await vi.importActual<typeof import('../../core/config.js')>('../../core/config.js')
return {
...actual,
writeConfigSection: mocks.writeConfigSection,
}
})

vi.mock('../../services/uta-supervisor/restart-trigger.js', () => ({
triggerUTARestart: mocks.triggerUTARestart,
}))

import { createConfigRoutes } from './config.js'

beforeEach(() => {
vi.clearAllMocks()
mocks.writeConfigSection.mockImplementation(async (_section, body) => body)
mocks.triggerUTARestart.mockResolvedValue({ triggered: true, ready: true })
})

describe('snapshot config route', () => {
it('requests a supervised UTA restart after persisting snapshot settings', async () => {
const routes = createConfigRoutes()
const response = await routes.request('/snapshot', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, every: '2h' }),
})

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ enabled: true, every: '2h' })
expect(mocks.writeConfigSection).toHaveBeenCalledWith('snapshot', {
enabled: true,
every: '2h',
})
await vi.waitFor(() => expect(mocks.triggerUTARestart).toHaveBeenCalledOnce())
})
})
10 changes: 5 additions & 5 deletions src/webui/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,11 +376,11 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) {
const fresh = await loadConfig()
Object.assign(opts.ctx.config, fresh)
}
// trading.json is consumed by the UTA process at boot (order-sync
// poller cadence) — bounce UTA via the Guardian flag protocol, same
// as broker config edits. Fire-and-forget: progress is visible
// through the health badges.
if (section === 'trading') {
// trading.json and snapshot.json are consumed by the UTA process at
// boot (order-sync and snapshot pump cadence) — bounce UTA via the
// Guardian flag protocol, same as broker config edits.
// Fire-and-forget: progress is visible through the health badges.
if (section === 'trading' || section === 'snapshot') {
triggerUTARestart().catch(() => { /* surfaced via health badges */ })
}
// marketData edits are picked up lazily by the provider resolver
Expand Down
2 changes: 2 additions & 0 deletions ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,8 @@ export interface WalletPushResult {
operationCount: number
submitted: Array<{ action: string; success: boolean; orderId?: string; status: string; error?: string }>
rejected: Array<{ action: string; success: boolean; error?: string; status: string }>
/** Demo-only marker: the response exercises the result UI without contacting a broker. */
simulated?: boolean
}

// ==================== Order / Trade History ====================
Expand Down
36 changes: 35 additions & 1 deletion ui/src/components/IssuesBoard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { IssueListItem, IssueSnapshot } from '../api/issues'
import { i18n } from '../i18n'
import { IssuesBoard } from './IssuesBoard'

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -52,7 +53,8 @@ function snapshot(issues: IssueListItem[]): IssueSnapshot {
}
}

beforeEach(() => {
beforeEach(async () => {
await i18n.changeLanguage('en')
mocks.useIssues.mockReturnValue({
data: snapshot([]),
error: null,
Expand Down Expand Up @@ -147,4 +149,36 @@ describe('IssuesBoard', () => {
expect(screen.getByText('Assign on first run')).toBeTruthy()
expect(screen.queryByText('@new')).toBeNull()
})

it('localizes board chrome, schedule health, and accessible metadata', async () => {
await i18n.changeLanguage('zh')
mocks.useIssues.mockReturnValue({
data: snapshot([
issue({
id: 'weekday-scan',
title: '工作日扫描',
status: 'in_progress',
priority: 'high',
assignee: '@new',
agent: 'claude',
when: { kind: 'cron', cron: '30 8 * * 1-5' },
automationHealth: { state: 'running', message: 'Run is active.' },
}),
]),
error: null,
loading: false,
})

render(<IssuesBoard />)

expect(screen.getByText('进行中')).toBeTruthy()
expect(screen.getAllByText('运行中')).toHaveLength(2)
expect(screen.getAllByText('每工作日 08:30')).toHaveLength(2)
expect(screen.getByText('首次运行时指派')).toBeTruthy()
expect(screen.getByText('claude 覆盖')).toBeTruthy()
expect(screen.getByLabelText('高优先级')).toBeTruthy()
expect(screen.getByLabelText('折叠“进行中”议题')).toBeTruthy()
expect(screen.getByTitle('打开 weekday-scan')).toBeTruthy()
expect(screen.getByTitle('工作区:market-desk(ws-1)')).toBeTruthy()
})
})
Loading
Loading