Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .github/workflows/zcode-transcript-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: ZCode transcript contract
on:
pull_request:
paths:
- 'src/main/ai-vault/session-scanner-zcode*'
- 'src/main/ai-vault/session-scanner-opencode*'
- 'src/main/ai-vault/opencode-wsl-runtime*'
- 'src/main/native-chat/transcript-opencode*'
- 'src/main/native-chat/transcript-zcode*'
- 'src/shared/native-chat-types.ts'
- 'src/shared/native-chat-transcript-projection*'
- '.github/workflows/zcode-transcript-contract.yml'
Comment on lines +4 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include transcript routing files in the workflow path filter.

If a PR changes only src/main/native-chat/transcript-reader.ts or src/shared/native-chat-agent-support.ts, this workflow does not run. Both files are part of the ZCode transcript routing covered by this contract. Add the routing and agent-support paths to the filter so those changes receive the cross-platform check.

workflow_dispatch:
permissions:
contents: read
concurrency:
group: zcode-transcript-contract-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
transcript-readers:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
- name: Verify SQLite ordering, legacy readers, and host routing
run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/native-chat/transcript-opencode2-sqlite.test.ts src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts src/main/native-chat/transcript-opencode-read-bounds.test.ts src/main/native-chat/transcript-opencode-subscribe.test.ts src/main/ai-vault/session-scanner-zcode.test.ts src/main/ai-vault/session-scanner-zcode-sources.test.ts src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts src/shared/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts',
'src/main/native-chat/transcript-opencode-sqlite-query.test.ts',
'src/main/native-chat/transcript-opencode-subscribe.test.ts',
'src/main/native-chat/transcript-zcode.test.ts',
'src/main/native-chat/transcript-opencode2-sqlite.test.ts',
'src/main/opencode-usage/cache-read-reproduction.test.ts',
'src/main/opencode-usage/opencode2-session-usage.test.ts',
Expand Down
15 changes: 9 additions & 6 deletions src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ describe('WSL SQLite runtime preparation', () => {
mocks.run.mockResolvedValueOnce(success(''))
await prepare([home])
await vi.waitFor(async () =>
expect((await prepare([home]))[0]?.error).toContain('No OpenCode database')
expect((await prepare([home]))[0]?.error).toContain('No OpenCode or ZCode database')
)
expect(mocks.run).toHaveBeenCalledOnce()
expect(mocks.download).not.toHaveBeenCalled()
Expand All @@ -274,16 +274,19 @@ describe('WSL SQLite runtime preparation', () => {
expect(mocks.running).toHaveBeenCalledWith(expect.any(Array), { requireConfirmed: true })
})

it.skipIf(process.platform === 'win32')(
'keeps the discovery default visible when guest overrides point elsewhere',
async () => {
it.skipIf(process.platform === 'win32').each(['opencode', 'zcode'] as const)(
'prepares a %s-only distro despite unrelated OpenCode overrides',
async (agent) => {
await prepared()
const script = mocks.run.mock.calls[0]?.[0].script
const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-presence-'))
try {
const data = join(directory, '.local', 'share', 'opencode')
const data =
agent === 'zcode'
? join(directory, '.zcode', 'cli', 'db')
: join(directory, '.local', 'share', 'opencode')
await mkdir(data, { recursive: true })
await writeFile(join(data, 'opencode.db'), '')
await writeFile(join(data, agent === 'zcode' ? 'db.sqlite' : 'opencode.db'), '')
const result = await runProcess({
program: '/bin/sh',
args: ['-c', script],
Expand Down
3 changes: 2 additions & 1 deletion src/main/ai-vault/opencode-wsl-runtime-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
const hasDatabase = await run({
script: [
'data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"',
'if [ -f "$HOME/.zcode/cli/db/db.sqlite" ]; then printf present; exit 0; fi',
// WSL discovery still enumerates the default data root independently of guest overrides.
'for db in "$HOME/.local/share/opencode"/opencode.db "$HOME/.local/share/opencode"/opencode-*.db; do if [ -f "$db" ]; then printf present; exit 0; fi; done',
'case "${OPENCODE_DB-}" in',
Expand All @@ -125,7 +126,7 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
loginPath: 'none'
})
if (hasDatabase !== 'present') {
return { distro, error: 'No OpenCode database is present in this WSL distro.' }
return { distro, error: 'No OpenCode or ZCode database is present in this WSL distro.' }
}
const readerPath = await run({
program: 'wslpath',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function handleOpenCodeSqliteRequest(
if (request.kind === 'native-page' || request.kind === 'native-signal') {
const value =
request.kind === 'native-signal'
? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId)
? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId, request.agent)
: readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 })
return { id: request.id, ok: true, value }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type OpenCodeNativeChatReadRequest = {
sessionId: string
limit?: number
beforeMessageRowId?: number
/** When 'zcode', reads apply ZCode's hidden-transcript filter. */
agent?: 'zcode'
}

export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker(
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
},
signal?: AbortSignal
) {
Expand All @@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker(
args: {
dbPath: string
sessionId: string
agent?: 'zcode'
},
signal?: AbortSignal
) {
Expand Down
51 changes: 51 additions & 0 deletions src/main/native-chat/transcript-opencode-database.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { homedir } from 'node:os'
import type { OpenCodeTranscriptSignal } from './transcript-opencode-sqlite-query'
import { WslTranscriptFsError } from './wsl-transcript-fs-error'

Expand All @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
),
prepare: vi.fn(async () => []),
configure: vi.fn(),
access: vi.fn(async () => true),
wslPath: vi.fn((_path: string): { distro: string; linuxPath: string } | null => null),
readSignal: vi.fn(
async (
Expand All @@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({
)
}))
vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: mocks.homes }))
vi.mock('./wsl-transcript-fs-access', () => ({ wslGatedAccess: mocks.access }))
vi.mock('../opencode-usage/opencode-database-discovery', () => ({
listOpenCodeDatabases: mocks.native,
listOpenCodeDatabasesInDirectory: mocks.directory,
Expand Down Expand Up @@ -50,6 +53,54 @@ afterEach(() => {
})

describe('OpenCode transcript owning-host database discovery', () => {
it('reports a missing ZCode install as unavailable without a SQLite refusal', async () => {
mocks.access.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
await expect(
openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode')
).resolves.toBeNull()
expect(mocks.readSignal).not.toHaveBeenCalled()
})
it('opens a native ZCode session before an unresolved WSL lookup', async () => {
vi.useFakeTimers()
mocks.homes.mockImplementation(() => new Promise(() => {}))
mocks.readSignal.mockResolvedValue({
messageCount: 1,
partCount: 1,
maxMessageRowId: 1,
maxPartTimeUpdated: 1
})
const settled = vi.fn()
void openCodeTranscriptDefaultDeps
.resolveDbPath('z-session', undefined, 'zcode')
.then(settled, settled)
await vi.advanceTimersByTimeAsync(50)
expect(settled).toHaveBeenCalledWith(join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite'))
expect(mocks.homes).not.toHaveBeenCalled()
expect(mocks.native).not.toHaveBeenCalled()
})

it('finds a WSL ZCode session without probing OpenCode databases', async () => {
const home = 'wsl-home'
const dbPath = join(home, '.zcode', 'cli', 'db', 'db.sqlite')
mocks.homes.mockResolvedValue([home])
mocks.readSignal.mockResolvedValueOnce(null).mockResolvedValueOnce({
messageCount: 1,
partCount: 1,
maxMessageRowId: 1,
maxPartTimeUpdated: 1
})
await expect(
openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode')
).resolves.toBe(dbPath)
expect(mocks.prepare).toHaveBeenCalledWith([home])
expect(mocks.readSignal).toHaveBeenLastCalledWith(
{ dbPath, sessionId: 'z-session', agent: 'zcode' },
expect.any(AbortSignal)
)
expect(mocks.native).not.toHaveBeenCalled()
expect(mocks.directory).not.toHaveBeenCalled()
})

it.each(['homes', 'directory', 'prepare'] as const)(
'opens a matching native database without waiting for WSL %s',
async (stage) => {
Expand Down
64 changes: 59 additions & 5 deletions src/main/native-chat/transcript-opencode-database.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { homedir } from 'node:os'
import { wslGatedAccess } from './wsl-transcript-fs-access'
import { join } from 'node:path'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
Expand All @@ -13,9 +15,15 @@ import {
listOpenCodeDatabasesInDirectory
} from '../opencode-usage/opencode-database-discovery'

// ZCode sessions belong only to its own store.
function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] {
return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite'))
}

export async function discoverOpenCodeTranscriptDatabase(
sessionId?: string,
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'zcode'
): Promise<string | null> {
signal?.throwIfAborted()
const deadline = new AbortController()
Expand All @@ -41,26 +49,72 @@ export async function discoverOpenCodeTranscriptDatabase(
}
boundedSignal.throwIfAborted()
probed.add(dbPath)
if (!sessionId) {
return dbPath
}
try {
if (
agent === 'zcode' &&
!(await waitForPromiseWithSignal(
wslGatedAccess(dbPath, 'exact', boundedSignal),
boundedSignal
))
) {
continue
}
if (!sessionId) {
return dbPath
}
if (
await waitForPromiseWithSignal(
readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal),
readOpenCodeTranscriptSignalViaWorker(
{ dbPath, sessionId, ...(agent ? { agent } : {}) },
boundedSignal
),
boundedSignal
)
) {
return dbPath
}
} catch (error) {
boundedSignal.throwIfAborted()
if (
agent === 'zcode' &&
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
continue
}
refusals.push(error instanceof Error ? error : new Error(String(error)))
}
}
return null
}
try {
if (agent === 'zcode') {
const native = await findSession(zcodeTranscriptDatabasePaths([]))
if (native) {
return native
}
const homes = (await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)).slice(
0,
32
)
if (homes.length > 0) {
const readers = await waitForPromiseWithSignal(
prepareOpenCodeWslReaders(homes),
boundedSignal
)
boundedSignal.throwIfAborted()
configureOpenCodeWslReaders(readers)
}
const wsl = await findSession(zcodeTranscriptDatabasePaths(homes))
Comment thread
nwparker marked this conversation as resolved.
Resolved
if (wsl) {
return wsl
}
if (refusals[0]) {
throw refusals[0]
}
return null
}
const primary = await waitForPromiseWithSignal(
listOpenCodeDatabases(onRefusal, undefined, boundedSignal),
boundedSignal
Expand Down
17 changes: 17 additions & 0 deletions src/main/native-chat/transcript-opencode-fingerprint-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function pruneOpenCodeFingerprintCache(
fingerprints: Map<number, string>,
cap: number,
agent?: 'zcode'
): void {
if (fingerprints.size <= cap) {
return
}
const keys = [...fingerprints.keys()]
const newest = agent === 'zcode' ? keys.toReversed() : keys.sort((a, b) => b - a)
const keep = new Set(newest.slice(0, cap))
for (const rowid of fingerprints.keys()) {
if (!keep.has(rowid)) {
fingerprints.delete(rowid)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,14 @@ describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => {
})

it('reconstructs a full read beyond its 500-message window without losing a split row', async () => {
const { path, insert, prefix } = fixture(version)
const { db, path, insert, prefix } = fixture(version)
const expected: string[] = []
db.exec('BEGIN')
for (let index = 0; index < 260; index++) {
insert(String(index), mixed())
expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`)
}
db.exec('COMMIT')
const result = await readOpenCodeNativeChatTranscriptFull('session', {
resolveDbPath: async () => path,
readPage: async (args) => readOpenCodeTranscriptPage(args)
Expand Down
Loading
Loading