Merge remote-tracking branch 'origin/main' into err-pr-3-composer-drop-failures

This commit is contained in:
Jinjing
2026-09-14 14:38:58 -07:00
160 changed files with 4871 additions and 998 deletions
+8 -5
View File
@@ -235,11 +235,14 @@ jobs:
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
# Why the main repo's tags: package.json on a branch is as stale as the
# main it forked from, and stable patches never merge back into it.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag, which still owns that number.
# Releases-only let adhoc sit on a number already taken, so the updater
# would not install it. Empty on failure — the script then falls back
# to package.json.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
+13 -9
View File
@@ -90,7 +90,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: main
# Version helpers only read HEAD; published versions come from the release API.
# Version helpers only read HEAD; published versions come from git tags.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
@@ -209,17 +209,21 @@ jobs:
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag. That dragged hourlies backwards so
# electron-updater stopped offering them; dailies would do the same. A
# separate token because GH_TOKEN above is the App's, scoped to the
# daily repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
# Already-shipped channel tags are a second floor so unpublishing a
# buggy main release cannot drag this series below a daily already out.
channel_tags="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName \
--jq '.[].tagName' || true)"
published="$main_tags"$'\n'"$channel_tags"
echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
@@ -0,0 +1,22 @@
name: Git command termination runtime
on:
pull_request:
paths:
- 'src/main/git/command-runner/spawned-command-tree-kill*'
- '.github/workflows/git-command-termination-runtime.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
windows-exit:
runs-on: windows-latest
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Verify exited native child does not trigger taskkill
run: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/git/command-runner/spawned-command-tree-kill.test.ts
+20 -12
View File
@@ -137,7 +137,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.head_sha }}
# Version helpers only read HEAD; published versions come from the release API.
# Version helpers only read HEAD; published versions come from git tags.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
@@ -213,17 +213,25 @@ jobs:
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# hourly repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag. On 2026-09-14 we deleted v1.4.202's
# release for a bug; hourlies had already climbed to 1.4.203, then
# `gh release list` fell back to v1.4.201 and the next hourlies shipped
# as 1.4.202-hourly — which electron-updater will not install over
# 1.4.203-hourly or over the still-tagged 1.4.202. A separate token
# because GH_TOKEN above is the App's, scoped to the hourly repo. Empty
# on failure — the script then falls back to package.json, which is
# stale but never wrong enough to fail a build.
main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
# Already-shipped channel tags are a second floor: even if main's tag
# list is empty this run, a 1.4.203-hourly already out must not be
# followed by a 1.4.202-hourly.
channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \
--jq '.[].tagName' || true)"
published="$main_tags"$'\n'"$channel_tags"
echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
+1
View File
@@ -56,6 +56,7 @@ jobs:
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js'
const fakes = vi.hoisted(() => ({
configs: [] as Array<Record<string, unknown>>,
@@ -119,11 +120,16 @@ describe('PostgreSQL relay deadlines', () => {
})
expect(ddl.length).toBeGreaterThan(0)
expect(ddl).toContain(POSTGRES_STATEMENT_STATS_MIGRATION)
// Statements can open with a leading `--` rationale comment.
const body = (statement: string): string =>
statement.replace(/^(?:\s*--[^\n]*\n)*\s*/, '')
expect(
ddl.every((statement) => /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement)))
ddl.every(
(statement) =>
statement === POSTGRES_STATEMENT_STATS_MIGRATION ||
/^(?:CREATE|ALTER TABLE)\b/i.test(body(statement))
)
).toBe(true)
// The backfill is DML, so it stays on the deadline-bearing serving pool.
expect(ddl.some((statement) => statement.includes('INSERT INTO'))).toBe(false)
+2
View File
@@ -10,6 +10,7 @@ import {
type PostgresPoolPressureCounts
} from './postgres-pool-pressure.js'
import { applyPostgresSchema } from './postgres-schema-startup.js'
import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js'
import {
CellInventoryHoldSamples,
emptyCellInventoryHoldCounts,
@@ -619,6 +620,7 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at);
// auto-named; the replacement is named, so both statements are no-ops on a
// database the current schema created and neither can drop the other.
export const POSTGRES_SCHEMA_MIGRATIONS = [
POSTGRES_STATEMENT_STATS_MIGRATION,
`ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE relay_region_rehome_attempts
@@ -0,0 +1,121 @@
import { randomUUID } from 'node:crypto'
import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { openRelayDatabase } from './database.js'
import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js'
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
describePostgres('optional PostgreSQL statement statistics', () => {
let admin: pg.Client
let preloaded: boolean
const databases: string[] = []
const roles: string[] = []
beforeAll(async () => {
admin = new pg.Client({ connectionString: databaseUrl })
await admin.connect()
const result = await admin.query<{ loaded: boolean }>(
`SELECT 'pg_stat_statements' = ANY(string_to_array(
replace(current_setting('shared_preload_libraries'), ' ', ''), ','
)) AS loaded`
)
preloaded = result.rows[0]!.loaded
})
afterAll(async () => {
for (const database of databases) await admin.query(`DROP DATABASE IF EXISTS ${database}`)
for (const role of roles) await admin.query(`DROP ROLE IF EXISTS ${role}`)
await admin.end()
})
async function freshDatabase(): Promise<string> {
const name = `relay_stats_${randomUUID().replaceAll('-', '')}`
await admin.query(`CREATE DATABASE ${name}`)
databases.push(name)
const url = new URL(databaseUrl!)
url.pathname = `/${name}`
return url.toString()
}
async function connect(url: string): Promise<pg.Client> {
const client = new pg.Client({ connectionString: url, statement_timeout: 2_000 })
await client.connect()
return client
}
async function installed(client: pg.Client): Promise<boolean> {
const result = await client.query<{ present: boolean }>(
`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') AS present`
)
return result.rows[0]!.present
}
it('exposes an existing collector idempotently, and skips servers without one', async () => {
const url = await freshDatabase()
const database = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
await database.close()
const client = await connect(url)
try {
expect(await installed(client)).toBe(preloaded)
if (preloaded) {
const before = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info')
await client.query(POSTGRES_STATEMENT_STATS_MIGRATION)
const after = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info')
expect(after.rows).toEqual(before.rows)
await client.query('SELECT calls, wal_bytes, shared_blks_dirtied FROM public.pg_stat_statements LIMIT 1')
} else {
await client.query(POSTGRES_STATEMENT_STATS_MIGRATION)
expect(await installed(client)).toBe(false)
}
} finally {
await client.end()
}
})
it.each([false, true])('tolerates missing extension privileges (read settings: %s)', async (readSettings) => {
const client = await connect(await freshDatabase())
const role = `relay_stats_role_${randomUUID().replaceAll('-', '')}`
await admin.query(`CREATE ROLE ${role}`)
roles.push(role)
if (readSettings) await admin.query(`GRANT pg_read_all_settings TO ${role}`)
try {
await client.query(`SET ROLE ${role}`)
await client.query(POSTGRES_STATEMENT_STATS_MIGRATION)
expect(await installed(client)).toBe(false)
expect((await client.query<{ value: number }>('SELECT 42 AS value')).rows[0]!.value).toBe(42)
} finally {
await client.end()
}
})
it('serializes concurrent catalog creation across directors', async () => {
const url = await freshDatabase()
const clients = await Promise.all(Array.from({ length: 5 }, async () => await connect(url)))
try {
await Promise.all(clients.map(async (client) => await client.query(POSTGRES_STATEMENT_STATS_MIGRATION)))
expect(await installed(clients[0]!)).toBe(preloaded)
} finally {
await Promise.all(clients.map(async (client) => await client.end()))
}
})
it('yields to an in-progress installer instead of blocking startup', async () => {
const url = await freshDatabase()
const owner = await connect(url)
const contender = await connect(url)
try {
await owner.query('BEGIN')
await owner.query(`SELECT pg_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats'))`)
await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION)
expect(await installed(contender)).toBe(false)
await owner.query('COMMIT')
await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION)
expect(await installed(contender)).toBe(preloaded)
} finally {
await owner.end()
await contender.end()
}
})
})
@@ -0,0 +1,28 @@
// Expose an already-running collector; never preload a module or require elevated runtime privileges.
export const POSTGRES_STATEMENT_STATS_MIGRATION = `
DO $relay_statement_stats$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_settings
WHERE name = 'shared_preload_libraries'
AND 'pg_stat_statements' = ANY(string_to_array(replace(setting, ' ', ''), ','))
) OR EXISTS (
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'
) OR NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = 'pg_stat_statements'
) THEN
RETURN;
END IF;
IF NOT pg_try_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats')) THEN
RETURN;
END IF;
BEGIN
CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public;
EXCEPTION WHEN insufficient_privilege THEN
RAISE WARNING 'orca_relay_statement_stats_unavailable: insufficient privilege';
END;
END
$relay_statement_stats$;
`
+15
View File
@@ -2,6 +2,21 @@
This runbook applies to the stable Cloud Run director and the production-shaped GCE cells in both environments. It does not authorize a full Terraform apply: staging and production contain unrelated drift, so inspect a saved targeted plan and its destroy count before every apply.
## PostgreSQL statement statistics
Relay schema startup exposes `pg_stat_statements` when the server already preloads
that collector and the schema identity can install its extension. Servers without
the collector or the required privileges continue normally. Installation does not
change preload settings, reset collected counters, or require a database restart;
concurrent startups yield to one installer. An existing extension is left in place.
For SQL incidents, inspect bounded aggregates of `calls`, `total_exec_time`,
`shared_blks_read`, `shared_blks_dirtied`, and `wal_bytes`, scoped to the relay
database and identified query IDs. Compare counter deltas over the same interval
as fleet runtime metrics; retain the statistics reset timestamp. Do not export
query text, identities, credentials, or invoke `pg_stat_statements_reset()` during
an investigation. Treat an unavailable view as missing evidence, not zero work.
The relay is automatically active for entitled signed-in desktops. There is no rollout flag, cohort, or user toggle. The emergency product kill switch is the auth plane refusing relay-token exchange; use cell drains only to move or terminate existing data-plane work.
## Safety rules
+5 -2
View File
@@ -25,8 +25,11 @@ function compareTriples(a, b) {
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
* already running. Published tags are the only honest answer to "what number is
* taken"; package.json is a floor, not a source of truth.
* already running. Git tags (not GitHub releases) are the honest answer to "what
* number is taken": unpublishing a buggy cut deletes the GitHub release and
* leaves the tag, which still owns that number. Channel tags (`1.4.203-hourly.*`)
* are a second floor so that unpublish cannot drag the series backwards.
* package.json is a floor, not a source of truth.
*/
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
const fromPackage = parseVersionTriple(packageVersion)
@@ -39,6 +39,27 @@ describe('dev channel base version', () => {
)
})
// Why tags rather than GitHub releases: unpublishing a buggy cut deletes the
// GitHub release and leaves the tag. Releases-only then treated 1.4.202 as
// free, so hourlies sat on 1.4.202-hourly and sorted below that tagged stable.
it('climbs past a tagged stable that has no GitHub release', () => {
expect(resolveDevChannelBaseVersion('1.4.197', ['v1.4.201', 'v1.4.202'])).toBe('1.4.203')
})
// 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies
// had already shipped as 1.4.203. Without the channel tags as a floor, the
// next hourlies would have been 1.4.202-hourly, which electron-updater will
// not install over 1.4.203-hourly.
it('does not drop below an already-published channel version', () => {
expect(
resolveDevChannelBaseVersion('1.4.197', [
'v1.4.201',
'v1.4.202-hourly.202609141912',
'v1.4.203-hourly.202609140417'
])
).toBe('1.4.203')
})
it('treats package.json as a floor when it leads the tags', () => {
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
})
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
createHourlyBuildVersion,
formatHourlyReleaseName,
getHourlyBuildIdentity,
nextHourlyBuildNumber
} from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
@@ -120,3 +121,26 @@ describe('nextHourlyBuildNumber', () => {
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
})
})
describe('getHourlyBuildIdentity', () => {
// 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies
// had climbed to 1.4.203. Passing the leftover tag and the already-shipped
// hourly keeps the next build on 1.4.203 so electron-updater will still
// install it.
it('stays on the already-shipped hourly base after a buggy main release is unpublished', () => {
const identity = getHourlyBuildIdentity(new Date('2026-09-14T20:00:00Z'), {
publishedVersions: [
'v1.4.201',
'v1.4.202',
'v1.4.202-hourly.202609141912',
'v1.4.203-hourly.202609140417'
],
releaseNames: [
'1.4.202 • 14 • Sep 14, 12:12PM • 875b86d',
'1.4.203 • 04 • Sep 13, 9:17PM • 2ce252f'
]
})
expect(identity.version).toBe('1.4.203-hourly.202609142000')
expect(identity.buildNumber).toBe(5)
})
})
@@ -15,6 +15,7 @@ const shellContractFiles = [
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
@@ -30,6 +30,25 @@ describe('ref-mirroring vet steps', () => {
).toBe(true)
})
// Why matching-refs rather than `gh release list` on the main repo: a tagged
// stable still owns its number after its GitHub release is unpublished for a
// bug, and that unpublish must not drag the channel backwards.
it.each(['daily', 'hourly', 'adhoc'])(
'%s versions from git tags, not main GitHub releases',
(channel) => {
const step = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[
`build-${channel}-mac`
].steps.find((candidate) => candidate.name === `Compute ${channel} version`)
expect(step.run).toContain('git/matching-refs/tags/v')
expect(step.run).not.toMatch(
/gh release list[\s\S]*--repo "\$GITHUB_REPOSITORY"[\s\S]*--json tagName/
)
if (channel !== 'adhoc') {
expect(step.run).toContain('channel_tags=')
}
}
)
it('retains release-cut history for version reservation and retry ancestry', () => {
const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find(
(step) => step.uses === 'actions/checkout@v6'
+34
View File
@@ -0,0 +1,34 @@
# OMP history titles
The message-graph scanner uses persisted OMP names ahead of the first user prompt:
`session.title`, version-1 `title` slots, `title_change.title`, and legacy
`session_info.name`. Empty or unsupported metadata leaves the previous name or
prompt fallback intact. Non-OMP graph parsing keeps its existing title policy.
Explicit user names outrank automatic names. Within the same source, timestamps
prevent the current first-line title slot from being replaced by older rename
entries later in the file. Newer appended renames still update the row. Legacy
records without timestamps retain file-order handling.
The graph fold stores title authority alongside the existing accumulator. Clones
retain it without sharing mutable accumulator or preview state, while preserving
the existing identity and message-consumer contracts. Cached append parsing uses
the normal durable offset; no extra scan, process, poll or watcher is introduced.
The parser is shared by local and remote content readers and uses transcript data
from the execution host. It performs no client-side path lookup and changes no
wire shape. Folder workspaces require no git metadata.
Run actual persistence and cache validation with a read-only OMP checkout:
```sh
ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-history-title-smoke.mjs /path/to/oh-my-pi
```
The smoke persists a first prompt, performs a real OMP user rename, and verifies
both cold and incrementally cached scans. It checks one full parse, one append
parse and identical-object reuse on an unchanged scan. All home/config/data roots
are disposable; no model requests are made.
This is the OMP subset of the history-name behavior proposed in PR #15696 by
Brennan Benson. Pi naming and title changes in the terminal are separate concerns.
+1
View File
@@ -184,6 +184,7 @@ Connect from the app using endpoint `ws://localhost:6768` and token `mock-device
### Environment variables
- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
- `MOCK_CHAT_AGENT=omp` — with `MOCK_NATIVE_CHAT=1`, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits `transcriptPath` to exercise legacy-hook readability discovery; current OMP hooks may report a path.
- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
### Scenario control files
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { WebSocket } from 'ws'
import type { AgentStatusEntry } from '../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../src/shared/native-chat-types'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTerminalClientTab
@@ -25,6 +26,10 @@ const TAB_ID = 'chat-tab-1'
const SESSION_ID = 'mock-chat-session'
const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl')
const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png')
// Exercise legacy OMP hooks without a transcript path; current hooks may include one.
const CHAT_AGENT = process.env.MOCK_CHAT_AGENT === 'omp' ? 'omp' : 'claude'
const CHAT_TITLE = CHAT_AGENT === 'omp' ? 'OMP' : 'Claude Code'
const TRANSCRIPT_START = Date.now() - 1000 * 60 * 5
function readControl(file: string): string {
try {
@@ -41,28 +46,27 @@ const agentStatus: AgentStatusEntry = {
prompt: '',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'claude',
agentType: CHAT_AGENT,
paneKey: `${TAB_ID}:leaf-1`,
terminalHandle: TERMINAL_HANDLE,
stateHistory: [],
providerSession: {
key: 'session_id',
id: SESSION_ID,
transcriptPath: TRANSCRIPT_PATH
}
providerSession:
CHAT_AGENT === 'omp'
? { key: 'session_id', id: SESSION_ID }
: { key: 'session_id', id: SESSION_ID, transcriptPath: TRANSCRIPT_PATH }
}
function buildTab(): RuntimeMobileSessionTerminalClientTab {
return {
type: 'terminal',
id: TAB_ID,
title: 'Claude Code',
title: CHAT_TITLE,
parentTabId: TAB_ID,
leafId: 'leaf-1',
ptyId: 'pty-1',
status: 'ready',
terminal: TERMINAL_HANDLE,
launchAgent: 'claude',
launchAgent: CHAT_AGENT,
agentStatus,
viewMode: 'chat',
isActive: true
@@ -104,6 +108,51 @@ function worktreeOf(request: RpcRequest): string {
return typeof raw === 'string' ? raw : 'id:mock-worktree'
}
// Why: shapes mirror what the runtime's omp decoder emits for a real session
// (thinking→text on the assistant turn, toolCall blocks, toolResult turns), so
// the phone exercises the same render path a live omp pane would.
function mockTranscript(): NativeChatMessage[] {
if (CHAT_AGENT !== 'omp') {
return []
}
const t = TRANSCRIPT_START
return [
{
id: 'omp-1',
role: 'user',
blocks: [{ type: 'text', text: 'why is my deploy failing?' }],
timestamp: t,
source: 'transcript'
},
{
id: 'omp-2',
role: 'assistant',
blocks: [
{ type: 'text', text: 'Let me check the deploy logs first.' },
{ type: 'tool-call', name: 'bash', input: { command: 'kubectl get pods' } }
],
timestamp: t + 1000,
source: 'transcript'
},
{
id: 'omp-3',
role: 'tool',
blocks: [{ type: 'tool-result', output: 'api-7f9c 0/1 CrashLoopBackOff' }],
timestamp: t + 2000,
source: 'transcript'
},
{
id: 'omp-4',
role: 'assistant',
blocks: [
{ type: 'text', text: 'The API pod is crash-looping. Check its logs with kubectl logs.' }
],
timestamp: t + 3000,
source: 'transcript'
}
]
}
// Why: unsubscribe correlates by worktree, not request id, and a socket that
// navigates A->B->A would otherwise stack one push loop per subscribe.
const tabsPushLoops = new Map<WebSocket, Map<string, ReturnType<typeof setInterval>>>()
@@ -144,7 +193,7 @@ type Respond = (response: RpcResponse) => void
type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse
type Failure = (id: string, code: string, message: string) => RpcResponse
/** Mock backend for the native-chat surface: session tabs, an empty transcript
/** Mock backend for the native-chat surface: session tabs, a fixture transcript
* snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1
* because it replaces the default terminal fixtures. No transcript or terminal
* output frames are pushed. Returns false for methods it does not own. */
@@ -194,7 +243,7 @@ export function handleMockNativeChatRequest(
const entry = (handle: string) => ({
handle,
worktreeId,
title: 'Claude Code',
title: CHAT_TITLE,
isActive: true,
hasRunningProcess: true
})
@@ -209,11 +258,13 @@ export function handleMockNativeChatRequest(
}
case 'nativeChat.subscribe':
respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true))
respond(
success(request.id, { type: 'snapshot', messages: mockTranscript(), hasMore: false }, true)
)
return true
case 'nativeChat.readSession':
respond(success(request.id, { messages: [], hasMore: false }))
respond(success(request.id, { messages: mockTranscript(), hasMore: false }))
return true
case 'terminal.subscribe': {
@@ -39,8 +39,8 @@ __orca_restore_agent_teams_path
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
'__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;;
esac
return 1
}
@@ -77,8 +77,8 @@ __orca_deferred_init() {
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
'__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;;
esac
return 1
}
@@ -42,8 +42,8 @@ __orca_restore_agent_teams_path
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
'__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;;
esac
return 1
}
@@ -77,8 +77,8 @@ __orca_deferred_init() {
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
'__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;;
esac
return 1
}
@@ -31,8 +31,8 @@ fi
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
'__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;;
esac
return 1
}
@@ -51,8 +51,8 @@ __orca_deferred_init() {
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
'__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;;
esac
return 1
}
@@ -199,30 +199,32 @@ export class SessionSearchStore {
files(): SessionSearchFileRow[] {
return (
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The files schema and SELECT aliases define this row; REAL casts return numeric IDs or null.
this.db
.prepare(
// Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range.
`SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino,
(
this.db
.prepare(
// Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range.
`SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino,
mtime_ms AS mtimeMs, size_bytes AS sizeBytes,
state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs
FROM files`
)
.all() as (Omit<SessionSearchFileRow, 'identity'> & {
dev: number | null
ino: number | null
})[]
).map((row) => ({
path: row.path,
identity:
typeof row.dev === 'number' && typeof row.ino === 'number'
? { dev: row.dev, ino: row.ino }
: null,
mtimeMs: row.mtimeMs,
sizeBytes: row.sizeBytes,
state: row.state,
failCount: row.failCount,
failedMtimeMs: row.failedMtimeMs
}))
)
.all() as (Omit<SessionSearchFileRow, 'identity'> & {
dev: number | null
ino: number | null
})[]
).map((row) => ({
path: row.path,
identity:
typeof row.dev === 'number' && typeof row.ino === 'number'
? { dev: row.dev, ino: row.ino }
: null,
mtimeMs: row.mtimeMs,
sizeBytes: row.sizeBytes,
state: row.state,
failCount: row.failCount,
failedMtimeMs: row.failedMtimeMs
}))
)
}
/**
@@ -1,3 +1,4 @@
import { foldOmpTranscriptTitle, type OmpTranscriptTitle } from './session-scanner-omp-title'
import {
remoteSessionContentLines,
type RemoteSessionContent
@@ -15,7 +16,8 @@ import type {
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
accumulatorSessionIdentity,
cloneSessionAccumulator,
addPreviewContent,
addPreviewMessage,
createAccumulator,
@@ -212,12 +214,24 @@ export async function parseMessageGraphSessionContent(
})
}
function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void {
type MessageGraphParseState = {
accumulator: SessionAccumulator
ompTitle: OmpTranscriptTitle | null
}
function consumeMessageGraphRecordLine(state: MessageGraphParseState, line: string): void {
const { accumulator } = state
const record = parseJsonObject(line)
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
if (accumulator.agent === 'omp') {
state.ompTitle = foldOmpTranscriptTitle(state.ompTitle, record)
if (state.ompTitle) {
accumulator.title = state.ompTitle.title
}
}
if (record.type === 'session') {
const sessionId = extractString(record.id)
if (sessionId) {
@@ -241,7 +255,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??= extractMessageText(message)
if (accumulator.agent === 'omp') {
accumulator.fallbackTitle ??= extractMessageText(message)
} else {
accumulator.title ??= extractMessageText(message)
}
} else {
accumulator.model = extractString(message?.model) ?? accumulator.model
accumulator.totalTokens += tokenTotal(message?.usage)
@@ -255,16 +273,38 @@ export function createMessageGraphSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
const state = accumulatorFoldResumeState(
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }),
consumeMessageGraphRecordLine
)
const state = createMessageGraphResumeState({
accumulator: createAccumulator({
agent,
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
ompTitle: null
})
// Why: only OMP materializes task-subagent transcripts beside its sessions
// (in the same-named artifact dir); the row UI shows the count without
// expanding details. Pi/OpenClaw/Prime Agent have no such layout — skip the readdir.
return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state
}
function createMessageGraphResumeState(state: MessageGraphParseState): ResumableSessionParseState {
return {
consumeLine: (line) => consumeMessageGraphRecordLine(state, line),
identity: () => accumulatorSessionIdentity(state.accumulator),
clone: () =>
createMessageGraphResumeState({
accumulator: cloneSessionAccumulator(state.accumulator),
ompTitle: state.ompTitle
}),
touchFile: (file) => {
state.accumulator.modifiedAt = file.modifiedAt
},
finalize: (platform, options) =>
finalizeSession(cloneSessionAccumulator(state.accumulator), platform, options)
}
}
async function parseMessageGraphSessionLines(args: {
agent: MessageGraphAgent
file: FileWithMtime
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
createMessageGraphSessionResumeState,
parseMessageGraphSessionContent
} from './session-scanner-graph-parsers'
const file = { path: '/tmp/omp-title.jsonl', mtimeMs: 1, modifiedAt: '2026-09-14T00:00:00.000Z' }
const prompt = { type: 'message', message: { role: 'user', content: 'First prompt' } }
const header = { type: 'session', id: 'session-id', cwd: '/folder workspace' }
const line = (record: unknown) => JSON.stringify(record)
async function parse(records: unknown[], agent: 'omp' | 'pi' = 'omp') {
return parseMessageGraphSessionContent(
agent,
file,
[header, ...records].map(line).join('\n'),
'darwin'
)
}
describe('OMP stored history names', () => {
it.each([
{ type: 'session', title: 'Harness name', titleSource: 'user' },
{
type: 'title',
v: 1,
title: 'Harness name',
source: 'user',
updatedAt: '2026-09-14T01:00:00Z',
pad: ''
},
{ type: 'title_change', title: 'Harness name', source: 'user' },
{ type: 'session_info', name: 'Harness name' }
])('uses persisted %j ahead of the first prompt', async (record) => {
expect((await parse([prompt, record]))?.title).toBe('Harness name')
})
it('preserves a user name through stale header and later automatic records', async () => {
expect(
(
await parse([
{
type: 'title',
v: 1,
title: 'User name',
source: 'user',
updatedAt: '2026-09-14T02:00:00Z',
pad: ''
},
{ ...header, title: 'Old header' },
prompt,
{
type: 'title_change',
title: 'Auto name',
source: 'auto',
timestamp: '2026-09-14T03:00:00Z'
}
])
)?.title
).toBe('User name')
})
it('keeps the current slot ahead of older rename entries, allowing a newer rename', async () => {
const records = [
{
type: 'title',
v: 1,
title: 'Current slot',
source: 'user',
updatedAt: '2026-09-14T02:00:00Z',
pad: ''
},
prompt,
{
type: 'title_change',
title: 'Old rename',
source: 'user',
timestamp: '2026-09-14T01:00:00Z'
}
]
expect((await parse(records))?.title).toBe('Current slot')
expect(
(
await parse([
...records,
{
type: 'title_change',
title: 'New rename',
source: 'user',
timestamp: '2026-09-14T03:00:00Z'
}
])
)?.title
).toBe('New rename')
})
it('preserves fallback behavior for missing, empty or unsupported title records', async () => {
expect(
(
await parse([
prompt,
{ type: 'title_change', title: ' ', source: 'user' },
{ type: 'title_change', title: 'Unknown', source: 'model' },
{ type: 'session_info', title: 'Wrong field' }
])
)?.title
).toBe('First prompt')
expect(
(await parse([prompt, { type: 'title_change', title: 'OMP only', source: 'user' }], 'pi'))
?.title
).toBe('First prompt')
})
it('clones title authority for append parsing without mutating previous snapshots', async () => {
const state = createMessageGraphSessionResumeState('omp', file)
for (const record of [
header,
prompt,
{ type: 'title_change', title: 'User name', source: 'user' }
]) {
state.consumeLine(line(record))
}
const previous = await state.finalize('darwin')
const next = state.clone()
next.consumeLine(line({ type: 'title_change', title: 'Auto name', source: 'auto' }))
expect((await next.finalize('darwin'))?.title).toBe('User name')
next.consumeLine(line({ type: 'title_change', title: 'New name', source: 'user' }))
expect((await next.finalize('darwin'))?.title).toBe('New name')
expect(previous?.title).toBe('User name')
expect(state.identity?.()?.title).toBe('User name')
})
})
@@ -0,0 +1,49 @@
import { extractString, normalizeTitleText, timestampMs } from './session-scanner-values'
export type OmpTranscriptTitle = {
title: string
source: 'user' | 'auto'
updatedAt: number | null
}
/** Fold persisted title metadata; a current slot can precede older rename entries. */
export function foldOmpTranscriptTitle(
current: OmpTranscriptTitle | null,
record: Record<string, unknown>
): OmpTranscriptTitle | null {
const legacy = record.type === 'session_info'
if (
!legacy &&
record.type !== 'session' &&
record.type !== 'title_change' &&
record.type !== 'title'
) {
return current
}
if (record.type === 'title' && record.v !== 1) {
return current
}
const title = normalizeTitleText(extractString(legacy ? record.name : record.title) ?? '')
if (!title) {
return current
}
const rawSource = legacy ? 'user' : (record.source ?? record.titleSource)
if (rawSource !== undefined && rawSource !== 'user' && rawSource !== 'auto') {
return current
}
const source = rawSource === 'user' ? 'user' : 'auto'
if (current?.source === 'user' && source !== 'user') {
return current
}
const timestamp = timestampMs(record.type === 'title' ? record.updatedAt : record.timestamp)
const updatedAt = Number.isFinite(timestamp) ? timestamp : null
if (
current?.source === source &&
current.updatedAt !== null &&
updatedAt !== null &&
updatedAt < current.updatedAt
) {
return current
}
return { title, source, updatedAt }
}
@@ -1,8 +1,10 @@
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
import { closeCodexPublishedSession } from './codex-structured-session-close'
import type { CodexSession } from './codex-structured-session-state'
@@ -48,17 +50,30 @@ describe('requested-close durable turn timing', () => {
observedAt: 1_000
})
).toEqual({ accepted: true })
const session = {
connection: { close: vi.fn(async () => true) },
const session: CodexSession = {
connection: {
pid: 4321,
closed: false,
request: async () => ({}),
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
},
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
prompts: { clear: vi.fn() },
historyPath: null,
prompts: new CodexPromptRegistry(),
options: new Map(),
reportedOptions: {},
fastModeTierByModel: new Map(),
dispatchEchoes: createCodexDispatchEchoes(),
translator
} as unknown as CodexSession
}
const sessions = new Map([['session-1', session]])
const onEvent = vi.fn()
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import { MAX_CODEX_PENDING_DISPATCH_ECHOES } from './codex-structured-dispatch-echo'
import {
acquiredCodexAdapter,
echoUserMessage,
fakeCodexAppServer,
startTurn,
CODEX_TEST_THREAD_ID,
CODEX_TEST_USER_MESSAGE,
type LateSettlement
} from './codex-structured-dispatch-test-support'
function send(
adapter: Awaited<ReturnType<typeof acquiredCodexAdapter>>,
clientMessageId: string
): Promise<unknown> {
return adapter.dispatch({
sessionId: 'session-1',
clientMessageId,
body: CODEX_TEST_USER_MESSAGE,
fence: 7
})
}
describe('codex dispatch admission', () => {
it('admits a send queued behind a running turn and settles it when Codex echoes it', async () => {
// Measured on codex-cli 0.153.4: a `turn/start` issued while a turn runs is
// COALESCED into it -- same turn id back, no second `turn/started`, and the
// user message echoed only once the running turn reaches it.
const codex = fakeCodexAppServer({
'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } })
})
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' })
const outcome = await send(adapter, 'client-2')
// No doubt: elapsed time is not evidence, so nothing invites a Retry.
expect(outcome).toEqual({ state: 'admitted' })
expect(settlements).toEqual([])
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' })
// Ordinal 1, not 0: the queued send is the SECOND user message of the turn
// it was coalesced into, which is the key a history replay computes for it.
expect(settlements).toEqual([
{
sessionId: 'session-1',
clientMessageId: 'client-2',
providerIdentity: {
provider: 'codex',
threadId: CODEX_TEST_THREAD_ID,
turnId: 'turn-1',
ordinal: 1
}
}
])
})
it('correlates each send by client message id, not queue order', async () => {
const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
await send(adapter, 'client-1')
await send(adapter, 'client-2')
// The echoes arrive in the opposite order to the sends.
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' })
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' })
// Ordinals follow the ECHO order, and each one lands on the send whose
// `clientId` it carried -- not on the send that was queued in that slot.
expect(settlements).toEqual([
{
sessionId: 'session-1',
clientMessageId: 'client-2',
providerIdentity: {
provider: 'codex',
threadId: CODEX_TEST_THREAD_ID,
turnId: 'turn-1',
ordinal: 0
}
},
{
sessionId: 'session-1',
clientMessageId: 'client-1',
providerIdentity: {
provider: 'codex',
threadId: CODEX_TEST_THREAD_ID,
turnId: 'turn-1',
ordinal: 1
}
}
])
})
it('settles nothing for a user message this session never sent', async () => {
const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
await send(adapter, 'client-1')
// A message another client sent on the same thread, and one Codex did not
// correlate at all.
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-x', clientId: 'someone-else' })
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-y' })
expect(settlements).toEqual([])
})
it('rejects only when Codex answered and declined, and arms nothing for it', async () => {
const { CodexAppServerRequestError } = await import('./codex-app-server-connection')
const codex = fakeCodexAppServer({
'turn/start': () => {
throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found')
}
})
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
expect(await send(adapter, 'client-1')).toEqual({
state: 'rejected',
reason: 'thread not found'
})
// A refused write is disarmed, so a later echo of that id settles nothing.
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' })
expect(settlements).toEqual([])
})
it('retains correlation when a request fails after its write may have landed', async () => {
const codex = fakeCodexAppServer({
'turn/start': () => {
throw new Error('request timed out after write')
}
})
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
await expect(send(adapter, 'client-1')).rejects.toThrow('request timed out after write')
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' })
expect(settlements).toEqual([
{
sessionId: 'session-1',
clientMessageId: 'client-1',
providerIdentity: {
provider: 'codex',
threadId: CODEX_TEST_THREAD_ID,
turnId: 'turn-1',
ordinal: 0
}
}
])
})
it('refuses overflow without discarding an older accepted send', async () => {
const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) {
expect(await send(adapter, `client-${index}`)).toEqual({ state: 'admitted' })
}
expect(await send(adapter, 'client-overflow')).toEqual({
state: 'rejected',
reason: 'codex structured dispatch queue is full'
})
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' })
expect(settlements.map(({ clientMessageId }) => clientMessageId)).toEqual(['client-0'])
})
it('leaves no waiter behind when the session closes', async () => {
const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
await send(adapter, 'client-1')
await adapter.closeSession('session-1')
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' })
expect(settlements).toEqual([])
})
it('leaves no waiter behind when the child exits', async () => {
const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'turn-1')
await send(adapter, 'client-1')
connection.handlers.onExit?.(new Error('codex app-server exited'))
echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' })
expect(settlements).toEqual([])
})
})
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import {
createCodexDispatchEchoes,
readCodexDispatchEcho,
MAX_CODEX_PENDING_DISPATCH_ECHOES
} from './codex-structured-dispatch-echo'
const CODEX_IDENTITY: AgentJournalItemIdentity = {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 3
}
describe('codex dispatch echoes', () => {
it('settles by client message id rather than arrival order', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.arm('client-2')
// Codex coalesces both sends into one turn, and the second can be echoed
// first. Queue position would settle the wrong submission here.
expect(echoes.settle('client-2')).toBe(true)
expect(echoes.settle('client-1')).toBe(true)
expect(echoes.size).toBe(0)
})
it('refuses an echo this session never armed', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
expect(echoes.settle('client-from-history')).toBe(false)
expect(echoes.size).toBe(1)
})
it('settles a send exactly once', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
expect(echoes.settle('client-1')).toBe(true)
expect(echoes.settle('client-1')).toBe(false)
})
it('drops a send whose write never reached the provider', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.disarm('client-1')
expect(echoes.settle('client-1')).toBe(false)
})
it('clears every armed send', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.arm('client-2')
echoes.clear()
expect(echoes.size).toBe(0)
expect(echoes.settle('client-1')).toBe(false)
})
it('refuses new correlations at capacity without dropping an older send', () => {
const echoes = createCodexDispatchEchoes()
for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) {
expect(echoes.arm(`client-${index}`)).toBe(true)
}
expect(echoes.arm(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false)
expect(echoes.size).toBe(MAX_CODEX_PENDING_DISPATCH_ECHOES)
expect(echoes.settle('client-0')).toBe(true)
expect(echoes.settle(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false)
})
})
describe('readCodexDispatchEcho', () => {
it('reads the client message id off a user message', () => {
expect(
readCodexDispatchEcho(
{ type: 'userMessage', id: 'item-1', clientId: 'client-1' },
CODEX_IDENTITY
)
).toEqual({ clientMessageId: 'client-1', providerIdentity: CODEX_IDENTITY })
})
it('ignores an item that is not a user message', () => {
expect(
readCodexDispatchEcho(
{ type: 'agentMessage', id: 'item-1', clientId: 'client-1' },
CODEX_IDENTITY
)
).toBeNull()
})
it('ignores a user message Codex did not correlate', () => {
expect(readCodexDispatchEcho({ type: 'userMessage', id: 'item-1' }, CODEX_IDENTITY)).toBeNull()
})
it('ignores an item with no durable Codex identity', () => {
expect(
readCodexDispatchEcho(
{ type: 'userMessage', id: 'item-1', clientId: 'client-1' },
{ provider: 'orca', clientMessageId: 'codex-item:thread-1:item-1' }
)
).toBeNull()
})
})
@@ -0,0 +1,58 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
/** Sends awaiting their echo, oldest first. A send whose echo never arrives is
* retired by the journal's pending-submission recovery on exit, not from here. */
export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256
/**
* Which sends this session is still waiting to hear back about, keyed by the
* client message id Codex echoes on the user message.
*
* Keyed rather than ordered on purpose: Codex coalesces a `turn/start` issued
* while a turn is running into that turn, so two sends can share one turn id and
* their echoes arrive far apart. Queue position identifies neither.
*/
export type CodexDispatchEchoes = {
/** Arms settlement for a send about to be written; false preserves older waits at capacity. */
arm: (clientMessageId: string) => boolean
/** True once, for a send this session armed and has not yet settled. */
settle: (clientMessageId: string) => boolean
/** Drops an armed send whose write never reached the provider. */
disarm: (clientMessageId: string) => void
clear: () => void
readonly size: number
}
export function createCodexDispatchEchoes(): CodexDispatchEchoes {
const armed = new Set<string>()
return {
arm(clientMessageId) {
if (!armed.has(clientMessageId) && armed.size >= MAX_CODEX_PENDING_DISPATCH_ECHOES) {
return false
}
armed.delete(clientMessageId)
armed.add(clientMessageId)
return true
},
settle: (clientMessageId) => armed.delete(clientMessageId),
disarm: (clientMessageId) => void armed.delete(clientMessageId),
clear: () => armed.clear(),
get size() {
return armed.size
}
}
}
/** The user-message echo a settlement is read off, or null for any other item. */
export function readCodexDispatchEcho(
item: { type: string; id: string } & Record<string, unknown>,
identity: AgentJournalItemIdentity
): { clientMessageId: string; providerIdentity: AgentJournalItemIdentity } | null {
if (item.type !== 'userMessage' || identity.provider !== 'codex') {
return null
}
const clientMessageId = item.clientId
return typeof clientMessageId === 'string' && clientMessageId.length > 0
? { clientMessageId, providerIdentity: identity }
: null
}
@@ -0,0 +1,139 @@
import type {
AgentJournalItemIdentity,
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
CodexAppServerLaunch,
openCodexAppServerConnection
} from './codex-app-server-connection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
export const CODEX_TEST_THREAD_ID = 'thread-abc'
export const CODEX_TEST_USER_MESSAGE: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'ship it' }]
}
export type CodexTestRoute = (params: Record<string, unknown> | undefined) => unknown
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
closed: boolean
launch: CodexAppServerLaunch
handlers: CodexAppServerConnectionHandlers
calls: { method: string; params?: Record<string, unknown> }[]
}
export type LateSettlement = {
sessionId: string
clientMessageId: string
providerIdentity: AgentJournalItemIdentity
}
/** A `codex app-server` whose turn traffic the test drives by hand. */
export function fakeCodexAppServer(routes: Record<string, CodexTestRoute> = {}): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
routes: Record<string, CodexTestRoute>
} {
const connections: FakeConnection[] = []
const openConnection = (async (launch, handlers = {}) => {
const connection: FakeConnection = {
launch,
handlers,
calls: [],
pid: 4321,
closed: false,
request: async (method, params) => {
connection.calls.push({ method, params })
return routes[method]?.(params) ?? {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
connection.closed = true
return true
}
}
connections.push(connection)
return connection
}) as typeof openCodexAppServerConnection
routes['thread/start'] ??= () => ({
thread: { id: CODEX_TEST_THREAD_ID, path: '/rollouts/abc.jsonl' }
})
return { connections, openConnection, routes }
}
/** A sink that records nothing but keeps the translator alive, which is what
* mints the identities a late settlement carries. */
export function recordingSink(): StructuredAgentSessionEventSink {
return {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {}
}
}
export async function acquiredCodexAdapter(input: {
codex: ReturnType<typeof fakeCodexAppServer>
settlements: LateSettlement[]
sink?: StructuredAgentSessionEventSink
}): Promise<CodexStructuredSessionAdapter> {
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}),
openConnection: input.codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
captureTurnProcesses: async () => null,
now: () => 1_700_000_000_500,
onDispatchSettledLate: (settlement) => input.settlements.push(settlement)
})
const identity: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID }
}
await adapter.acquire({
identity,
fence: 7,
spawnToken: 'spawn-9',
events: input.sink ?? recordingSink()
})
return adapter
}
/** Codex's own echo of a user message Orca sent, inside `turnId`. */
export function echoUserMessage(
connection: FakeConnection,
input: { turnId: string; itemId: string; clientId?: string; threadId?: string }
): void {
connection.handlers.onNotification?.('item/started', {
threadId: input.threadId ?? CODEX_TEST_THREAD_ID,
turn: { id: input.turnId },
item: {
type: 'userMessage',
id: input.itemId,
...(input.clientId ? { clientId: input.clientId } : {})
}
})
}
export function startTurn(connection: FakeConnection, turnId: string): void {
connection.handlers.onNotification?.('turn/started', {
threadId: CODEX_TEST_THREAD_ID,
turn: { id: turnId }
})
}
@@ -112,7 +112,9 @@ describe('Codex structured Fast mode dispatch', () => {
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({ state: 'accepted' })
// `admitted`, not `accepted`: a Codex send now settles its identity on
// the provider echo. What this test pins is the tier the turn carries.
).resolves.toMatchObject({ state: 'admitted' })
expect(
codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
).toMatchObject({ serviceTier: 'default' })
@@ -1,3 +1,4 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
@@ -15,6 +16,9 @@ export type CodexJournalTranslatorDeps = {
turnId?: string | null
) => void
clearPromptTurn?: (threadId: string, turnId: string) => void
/** Settles a send's identity off the echoed user message, using the very
* identity the journal row carries so a replay computes the same key. */
onUserMessageEcho?: (clientMessageId: string, identity: AgentJournalItemIdentity) => void
primaryThreadId?: () => string | null
subagentExecutions?: CodexSubagentExecutions
coalesceMs?: number
@@ -29,6 +29,7 @@ import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structu
import type { CodexActiveJournalItem } from './codex-structured-journal-settlement'
import { readCodexJournalString } from './codex-structured-journal-translation-values'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { readCodexDispatchEcho } from './codex-structured-dispatch-echo'
export class CodexJournalItems {
readonly ordinals = new CodexTurnOrdinals()
@@ -40,7 +41,7 @@ export class CodexJournalItems {
constructor(
private readonly deps: Pick<
CodexJournalTranslatorDeps,
'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule'
'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'onUserMessageEcho'
> & { maxMetadataBytes?: number },
private readonly activeTurn: (threadId: string) => string | null,
private readonly suppress: (threadId: string, turnId: string) => void
@@ -78,6 +79,10 @@ export class CodexJournalItems {
const identity = this.identityFor(event.threadId, turnId, item)
// Count echoes for stable resume ordinals, but user bubbles come from submissions.
if (source === 'live' && item.type === 'userMessage') {
const echo = readCodexDispatchEcho(item, identity)
if (echo) {
this.deps.onUserMessageEcho?.(echo.clientMessageId, echo.providerIdentity)
}
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (item.type === 'contextCompaction' && event.method === 'item/started') {
@@ -6,7 +6,7 @@ import type {
} from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { agentJournalTurnBody } from '../../shared/agent-session-turn-record'
import { CODEX_USER_MESSAGE_ORDINAL } from './codex-structured-turn-start'
import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
@@ -3,7 +3,7 @@ import { disposeCodexServerRequest } from './codex-server-request-disposition'
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation'
import * as codexRewind from './codex-structured-rewind'
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
import { readCodexThreadId } from './codex-structured-thread-facts'
import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
type EmitCodexEvent = (
@@ -41,10 +41,9 @@ export function deliverCodexNotification(
return { accepted: true }
}
const threadId = readCodexThreadId(params) ?? session.threadId
const turnId =
method === 'turn/started' && threadId === session.threadId ? readCodexTurnId(params) : null
const turnWaiter = turnId ? session.turnIdWaiters[0] : undefined
const admission = emit(session, {
// Dispatch identity settles on the user-message echo inside the translator,
// which is where the ordinal a replay will compute is minted.
return emit(session, {
type: 'notification',
sessionId,
threadId,
@@ -52,13 +51,6 @@ export function deliverCodexNotification(
params,
...(observedAt !== undefined ? { observedAt } : {})
})
if (method === 'turn/started' && threadId === session.threadId) {
if (admission.accepted && turnId && session.turnIdWaiters[0] === turnWaiter) {
session.turnIdWaiters.shift()
turnWaiter?.(turnId)
}
}
return admission
}
export function deliverCodexServerRequest(
@@ -10,6 +10,7 @@ import {
} from './codex-structured-acquisition-lifecycle'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'
@@ -81,6 +82,7 @@ export async function acquireCodexStructuredSession(input: {
? acquireInput.identity.providerHandle.threadId
: null
const subagentExecutions = new CodexSubagentExecutions()
const dispatchEchoes = createCodexDispatchEchoes()
const translator = acquireInput.events
? createCodexJournalTranslator({
sink: acquireInput.events,
@@ -90,7 +92,14 @@ export async function acquireCodexStructuredSession(input: {
subagentExecutions,
bindPromptItemId: (journalItemId, threadId, promptKey, turnId) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId),
clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId)
clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId),
onUserMessageEcho: (clientMessageId, providerIdentity) => {
// Only a send THIS session admitted; an echo from history restore or
// another client names no submission of ours to settle.
if (dispatchEchoes.settle(clientMessageId)) {
deps.onDispatchSettledLate?.({ sessionId, clientMessageId, providerIdentity })
}
}
})
: null
const open = deps.openConnection ?? openCodexAppServerConnection
@@ -230,7 +239,7 @@ export async function acquireCodexStructuredSession(input: {
options,
reportedOptions: reportedCodexThreadOptions(opened),
fastModeTierByModel: fastModeCatalog?.fastModeTierByModel ?? new Map(),
turnIdWaiters: [],
dispatchEchoes,
translator,
backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions),
forceCloseUnexpected: (reason) =>
@@ -315,7 +315,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
})
describe('CodexStructuredSessionAdapter.dispatch', () => {
it('accepts a turn Codex names in its response', async () => {
it('admits a send as soon as Codex owns it', async () => {
const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const adapter = await acquired(codex)
@@ -334,10 +334,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => {
fence: 7
})
expect(outcome).toEqual({
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 }
})
// Identity is not knowable here: a send coalesced into a running turn shares
// that turn's id, so the echo settles which message landed where.
expect(outcome).toEqual({ state: 'admitted' })
expect(codex.connections[0].calls[1].params).toEqual({
threadId: THREAD_ID,
clientUserMessageId: 'client-1',
@@ -349,7 +348,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => {
})
})
it('accepts a turn named only by the notification that raced the ack', async () => {
it('admits a send on a build whose turn/start answers before the turn is named', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
@@ -368,8 +367,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => {
fence: 7
})
expect(outcome).toMatchObject({ state: 'accepted' })
expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } })
expect(outcome).toEqual({ state: 'admitted' })
expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' })
})
@@ -393,10 +391,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => {
fence: 7
})
expect(outcome).toEqual({
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 }
})
expect(outcome).toEqual({ state: 'admitted' })
// Each event carries the thread it actually came from, so the journal can
// keep a subagent's turn out of the root conversation.
expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([
@@ -405,29 +400,6 @@ describe('CodexStructuredSessionAdapter.dispatch', () => {
])
})
it('settles unknown rather than failed when Codex never names the turn', async () => {
vi.useFakeTimers()
try {
const codex = fakeCodex()
const adapter = await acquired(codex)
const dispatching = adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
await vi.advanceTimersByTimeAsync(10_000)
expect(await dispatching).toEqual({
state: 'unknown',
reason: 'codex app-server started a turn it did not name in time'
})
} finally {
vi.useRealTimers()
}
})
it('rejects only when Codex answered and declined', async () => {
const codex = fakeCodex({
'turn/start': () => {
@@ -257,9 +257,8 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => {
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { turnId: 'turn-2' }
).resolves.toEqual({
state: 'admitted'
})
})
@@ -1,3 +1,4 @@
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
@@ -11,6 +12,7 @@ import {
} from './codex-structured-session-adapter'
import { handleCodexSessionExit } from './codex-structured-session-close'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
import type { CodexSession } from './codex-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
@@ -84,13 +86,13 @@ describe('Codex structured session close lifecycle', () => {
respondWithError: () => {},
close: async () => true
}
const prompts = { clear: vi.fn() } as unknown as CodexSession['prompts']
const prompts = new CodexPromptRegistry()
const clearPrompts = vi.spyOn(prompts, 'clear')
const translator = {
handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }),
dispose: vi.fn()
} as unknown as NonNullable<CodexSession['translator']>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal supplies every CodexSession field the close path reads; the rest are unused by it.
const session = {
const session: CodexSession = {
connection,
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
@@ -103,9 +105,9 @@ describe('Codex structured session close lifecycle', () => {
options: new Map(),
reportedOptions: {},
fastModeTierByModel: new Map(),
turnIdWaiters: [],
dispatchEchoes: createCodexDispatchEchoes(),
translator
} as CodexSession
}
const sessions = new Map([['session-1', session]])
const onEvent = vi.fn()
@@ -120,7 +122,7 @@ describe('Codex structured session close lifecycle', () => {
})
).toBe(true)
expect(session.ended).toBe(true)
expect(prompts.clear).toHaveBeenCalledOnce()
expect(clearPrompts).toHaveBeenCalledOnce()
expect(onEvent).toHaveBeenCalledOnce()
expect(translator.dispose).toHaveBeenCalledOnce()
expect(onEvent.mock.calls[0]?.[0]).toMatchObject({
@@ -47,6 +47,9 @@ export function handleCodexSessionExit(input: {
event.settlementRetryRequired = true
}
session.ended = true
// Nothing can echo for this child any more; the journal's pending-submission
// recovery is what settles the sends these were armed for.
session.dispatchEchoes.clear()
session.backgroundTasks.clear()
input.onBackgroundTasksChanged?.(input.sessionId, null)
session.unbindReadingControl?.()
@@ -1,3 +1,4 @@
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { describe, expect, it, vi } from 'vitest'
import type { CodexAppServerConnection } from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
@@ -34,7 +35,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi
options: new Map(),
reportedOptions: { model: 'gpt-live', effort: 'high' },
fastModeTierByModel: new Map(),
turnIdWaiters: [],
dispatchEchoes: createCodexDispatchEchoes(),
translator: null
}
}
@@ -1,4 +1,7 @@
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import { randomUUID } from 'node:crypto'
import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition'
import type {
@@ -6,6 +9,7 @@ import type {
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
@@ -58,6 +62,12 @@ export type CodexStructuredSessionAdapterDeps = {
sessionId: string,
state: AgentSessionBackgroundTaskState | null
) => void
/** Identity for a send admitted earlier, once Codex echoes the user message. */
onDispatchSettledLate?: (input: {
sessionId: string
clientMessageId: string
providerIdentity: AgentJournalItemIdentity
}) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
@@ -94,7 +104,8 @@ export type CodexSession = {
}
/** Exact provider-advertised Fast request value for each discovered model. */
fastModeTierByModel: Map<string, string>
turnIdWaiters: ((turnId: string) => void)[]
/** Sends whose identity is still to be settled by the provider echo. */
dispatchEchoes: CodexDispatchEchoes
translator: CodexJournalTranslator | null
/** Ephemeral roster behind the background-tasks strip; never durable state. */
backgroundTasks: CodexBackgroundTaskTracker
+39 -61
View File
@@ -6,21 +6,16 @@ import {
type CodexAppServerConnection
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons'
import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
// Starting a Codex turn and learning its id, which are not the same event:
// `turn/start` returns the id on newer builds and acks before it exists on
// older ones, where it arrives as a `turn/started` notification instead.
/** Codex records the user message first in a turn, so the submission Orca just
* accepted is ordinal 0 of `(threadId, turnId)`. */
export const CODEX_USER_MESSAGE_ORDINAL = 0
/** Past this the turn is real but unnameable, which the journal renders as
* delivery unconfirmed rather than failure. */
const TURN_ID_WAIT_MS = 10_000
// Writing a Codex turn and learning which message landed where, which are not
// the same event. `turn/start` answers as soon as Codex owns the message, but a
// message issued while a turn is running is COALESCED into that turn: the same
// turn id comes back, no second `turn/started` fires, and the user message is
// echoed only when the running turn reaches it. So the response proves
// admission and nothing about identity, which the echo settles later.
/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise
* become an arbitrary client-controlled `turn/start` parameter. */
@@ -38,16 +33,14 @@ export function isCodexTurnOptionKey(key: string): boolean {
return CODEX_TURN_OPTION_KEYS.has(key)
}
/** The session state one turn needs. `turnIdWaiters` is shared with the
* notification handler, which resolves the head of the queue — correct because
* Codex runs one turn per thread, so starts and `turn/started` share an order. */
/** The session state one turn needs. */
export type CodexTurnHost = {
connection: Pick<CodexAppServerConnection, 'request'>
threadId: string
options: Map<string, string>
reportedOptions?: { model?: string }
fastModeTierByModel: ReadonlyMap<string, string>
turnIdWaiters: ((turnId: string) => void)[]
dispatchEchoes: CodexDispatchEchoes
}
function turnInputFor(body: AgentJournalMessageItem): Record<string, unknown>[] {
@@ -92,69 +85,54 @@ function codexTurnOptions(host: CodexTurnHost): Record<string, string> {
}
/**
* Resolves the turn id, or null when Codex owns a turn it never named. Throws
* only for outcomes the wire must not read as acceptance.
* Hands one submission to Codex. False means the bounded correlation window
* refused it before the write; otherwise resolves when Codex has taken it.
*/
export async function startCodexTurn(
host: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number }
): Promise<string | null> {
// Registered BEFORE the call: on builds that ack first, `turn/started` can
// land while the response is still in flight.
let notified: ((turnId: string) => void) | null = null
const fromNotification = new Promise<string | null>((resolve) => {
notified = resolve
host.turnIdWaiters.push(resolve)
setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.()
})
try {
const started = await host.connection.request(
'turn/start',
{
threadId: host.threadId,
clientUserMessageId: input.clientMessageId,
input: turnInputFor(input.body),
...codexTurnOptions(host)
},
{ timeoutMs: input.timeoutMs }
)
return readCodexTurnId(started) ?? (await fromNotification)
} finally {
const index = notified ? host.turnIdWaiters.indexOf(notified) : -1
if (index !== -1) {
host.turnIdWaiters.splice(index, 1)
}
): Promise<boolean> {
// Armed before the write: the echo can land while the response is in flight.
if (!host.dispatchEchoes.arm(input.clientMessageId)) {
return false
}
await host.connection.request(
'turn/start',
{
threadId: host.threadId,
clientUserMessageId: input.clientMessageId,
input: turnInputFor(input.body),
...codexTurnOptions(host)
},
{ timeoutMs: input.timeoutMs }
)
return true
}
/**
* One submission's outcome as the wire must read it: accepted names the turn,
* rejected is Codex answering and declining, and unknown covers a turn that is
* real but unnameable — never a failure the user is told their message hit.
* One submission's outcome as the wire must read it: admitted means Codex owns
* the message and its identity settles on the echo, rejected is Codex answering
* and declining. Elapsed time is never evidence here, because the wait a
* coalesced send would face is bounded only by the running turn.
*/
export async function dispatchCodexTurn(
session: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem },
timeoutMs: number | undefined
): Promise<AgentSessionDispatchOutcome> {
let turnId: string | null
try {
turnId = await startCodexTurn(session, { ...input, timeoutMs })
if (!(await startCodexTurn(session, { ...input, timeoutMs }))) {
return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL }
}
} catch (error) {
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
// Codex answered and declined, so no echo for this write can arrive.
session.dispatchEchoes.disarm(input.clientMessageId)
return { state: 'rejected', reason: (error as Error).message }
}
// A timeout or transport failure can happen after the frame was written.
// Keep the correlation armed so a later echo can prove delivery.
throw error
}
return turnId === null
? { state: 'unknown', reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED }
: {
state: 'accepted',
providerIdentity: {
provider: 'codex',
threadId: session.threadId,
turnId,
ordinal: CODEX_USER_MESSAGE_ORDINAL
}
}
return { state: 'admitted' }
}
+4
View File
@@ -3,6 +3,10 @@ import {
digestPayload
} from '../native-chat/agent-session-journal/journal-payload-bounds'
/** Codex records the user message first in a turn, so a restored submission is
* ordinal 0 of `(threadId, turnId)`. */
export const CODEX_USER_MESSAGE_ORDINAL = 0
/** Maximum forgotten turn keys retained for late-frame reconciliation. */
export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256
export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024
@@ -0,0 +1,122 @@
import { ChildProcess } from 'node:child_process'
import { once } from 'node:events'
import { spawnProcess } from '../../../shared/child-process/run-process'
import type * as NodeChildProcess from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock, admitMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
admitMock: vi.fn(() => true)
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof NodeChildProcess>()),
spawn: spawnMock
}))
vi.mock('../../own-chromium-tree-kill-guard', () => ({
admitSelfInitiatedTreeKill: admitMock
}))
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
const originalPlatform = process.platform
function childWithPid(pid: number): ChildProcess {
const child = new ChildProcess()
Object.defineProperty(child, 'pid', { value: pid })
vi.spyOn(child, 'kill').mockReturnValue(true)
vi.spyOn(child, 'unref').mockImplementation(() => {})
return child
}
describe('Git command tree termination', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
spawnMock.mockReset()
admitMock.mockReset().mockReturnValue(true)
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
vi.restoreAllMocks()
})
it.each([0, 128])(
'never taskkills a child that exited with code %i before close',
async (code) => {
const child = childWithPid(1234)
Object.defineProperty(child, 'exitCode', { value: code })
await killSpawnedCommandTree(child)
expect(spawnMock).not.toHaveBeenCalled()
expect(admitMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledOnce()
}
)
it('never taskkills a child that exited by signal before close', async () => {
const child = childWithPid(1234)
Object.defineProperty(child, 'signalCode', { value: 'SIGTERM' })
await killSpawnedCommandTree(child)
expect(spawnMock).not.toHaveBeenCalled()
expect(admitMock).not.toHaveBeenCalled()
})
it('still waits for tree termination when the Windows root has not exited', async () => {
const child = childWithPid(1234)
const killer = childWithPid(5678)
spawnMock.mockReturnValue(killer)
let settled = false
const pending = killSpawnedCommandTree(child).then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
expect(spawnMock).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.emit('close', 0)
await pending
expect(child.kill).not.toHaveBeenCalled()
})
it('preserves handle termination on POSIX', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const child = childWithPid(1234)
await killSpawnedCommandTree(child)
expect(child.kill).toHaveBeenCalledOnce()
expect(spawnMock).not.toHaveBeenCalled()
})
it.skipIf(originalPlatform !== 'win32').each([0, 128])(
'does not taskkill an actual native Windows child after exit %i',
async (exitCode) => {
const original = await vi.importActual<typeof NodeChildProcess>('node:child_process')
spawnMock.mockImplementation((program, args, options) => {
if (program !== process.execPath) {
throw new Error('Unexpected external process in native exit probe')
}
return original.spawn(program, args, options)
})
const child = spawnProcess({
program: process.execPath,
args: ['-e', `process.exit(${exitCode})`]
})
const closed = once(child, 'close')
await once(child, 'exit')
expect(child.exitCode).toBe(exitCode)
expect(child.pid).toBeGreaterThan(0)
spawnMock.mockClear()
await killSpawnedCommandTree(child)
expect(spawnMock).not.toHaveBeenCalled()
expect(admitMock).not.toHaveBeenCalled()
await closed
}
)
})
@@ -9,6 +9,11 @@ export function killSpawnedCommandTree(child: ChildProcess): Promise<void> {
child.kill()
return Promise.resolve()
}
// Windows may reuse the pid after exit while inherited pipes still delay close.
if ((child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null) {
child.kill()
return Promise.resolve()
}
if (
!admitSelfInitiatedTreeKill({ pid, site: 'git-command-tree-kill', scope: 'win-taskkill-tree' })
) {
+42 -11
View File
@@ -73,6 +73,7 @@ describe('fs:importExternalPaths', () => {
size: 12,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
@@ -94,6 +95,7 @@ describe('fs:importExternalPaths', () => {
size: entry.isDir ? 0 : 12,
ino: entry.isDir ? 2 : 3,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => !entry.isDir,
isDirectory: () => entry.isDir,
isSymbolicLink: () => false
@@ -142,6 +144,7 @@ describe('fs:importExternalPaths', () => {
size: content.byteLength,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true
}),
createReadStream: () => Readable.from([content]),
@@ -216,6 +219,7 @@ describe('fs:importExternalPaths', () => {
size: 12,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true
}),
createReadStream: () => Readable.from([Buffer.from('file-content')]),
@@ -484,6 +488,7 @@ describe('fs:importExternalPaths', () => {
size: 4,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
@@ -498,6 +503,7 @@ describe('fs:importExternalPaths', () => {
size: 4,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true
}),
readFile: readFileHandleMock,
@@ -514,11 +520,21 @@ describe('fs:importExternalPaths', () => {
status: 'staged',
name: 'logo.png',
kind: 'file',
entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }]
entries: [
{
relativePath: '',
kind: 'file',
byteLength: 4,
inode: 1,
deviceId: 1,
modifiedAtMs: 1700000000000
}
]
}
])
expect(copyFileMock).not.toHaveBeenCalled()
expect(readFileHandleMock).toHaveBeenCalled()
// Why: bodies stream at upload time, so staging must never read the file.
expect(readFileHandleMock).not.toHaveBeenCalled()
expect(closeMock).toHaveBeenCalled()
})
@@ -533,6 +549,7 @@ describe('fs:importExternalPaths', () => {
size: 0,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
@@ -543,6 +560,7 @@ describe('fs:importExternalPaths', () => {
size: 4,
ino: 2,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
@@ -578,6 +596,7 @@ describe('fs:importExternalPaths', () => {
size: 4,
ino: 2,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true
}),
readFile: vi.fn().mockResolvedValue(Buffer.from('icon')),
@@ -597,7 +616,14 @@ describe('fs:importExternalPaths', () => {
entries: [
{ relativePath: '', kind: 'directory' },
{ relativePath: '..assets', kind: 'directory' },
{ relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' }
{
relativePath: '..assets/icon.txt',
kind: 'file',
byteLength: 4,
inode: 2,
deviceId: 1,
modifiedAtMs: 1700000000000
}
]
}
])
@@ -612,6 +638,7 @@ describe('fs:importExternalPaths', () => {
size: 0,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
@@ -637,14 +664,15 @@ describe('fs:importExternalPaths', () => {
expect(openMock).not.toHaveBeenCalled()
})
it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => {
it('checks runtime upload directory byte budget before opening a file that exceeds the total cap', async () => {
const sourcePath = '/tmp/dropped/project'
const resolvedPath = path.resolve(sourcePath)
const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) =>
path.join(resolvedPath, name)
)
const mib = 1024 * 1024
const regularSize = 25 * mib
// Four files exactly fill the 8 GB total ceiling; the fifth pushes past it.
const regularSize = 2 * 1024 * mib
const overflowSize = Number(mib)
const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk'))
@@ -654,6 +682,7 @@ describe('fs:importExternalPaths', () => {
size: 0,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
@@ -666,6 +695,7 @@ describe('fs:importExternalPaths', () => {
size,
ino: fileIndex + 2,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
@@ -689,6 +719,7 @@ describe('fs:importExternalPaths', () => {
size: regularSize,
ino: fileIndex + 2,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true
}),
readFile: readFileMock,
@@ -702,11 +733,9 @@ describe('fs:importExternalPaths', () => {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: 'Remote import is too large'
})
expect(readFileMock).toHaveBeenCalledTimes(4)
expect(result.sources[0]).toMatchObject({ status: 'failed' })
expect(result.sources[0]?.reason).toContain('total remote import limit')
expect(readFileMock).not.toHaveBeenCalled()
expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything())
})
@@ -719,6 +748,7 @@ describe('fs:importExternalPaths', () => {
size: 4,
ino: 1,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
@@ -732,6 +762,7 @@ describe('fs:importExternalPaths', () => {
size: 4,
ino: 2,
dev: 1,
mtimeMs: 1700000000000,
isFile: () => true
}),
readFile: readFileHandleMock,
@@ -744,7 +775,7 @@ describe('fs:importExternalPaths', () => {
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: "File changed during upload staging: ''"
reason: "File changed during upload staging: 'logo.png'"
})
expect(readFileHandleMock).not.toHaveBeenCalled()
})
@@ -0,0 +1,176 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (event: unknown, args: unknown) => Promise<unknown>>()
const { handleMock, streamMock, sweepMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
streamMock: vi.fn(),
sweepMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock },
app: { getPath: () => '/user-data' }
}))
vi.mock('./runtime-upload-file-stream', () => ({
streamExternalFileToRuntime: streamMock
}))
vi.mock('./runtime-upload-temp-sweep', () => ({
sweepAbandonedRuntimeUploadTempPath: sweepMock
}))
vi.mock('../../shared/runtime-environment-store', () => ({
resolveEnvironment: (_userDataPath: string, selector: string) => ({
id: selector === 'env-alias' ? 'env-1' : selector
})
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import { RENDERER_GONE_MESSAGE } from './renderer-lifetime-abort'
const request = {
environmentId: 'env-1',
sourceRootPath: '/drop/file.bin',
entryRelativePath: '',
expected: { byteLength: 1, inode: 1, deviceId: 1, modifiedAtMs: 1 },
worktree: 'wt-1',
relativePath: '.file.bin.orca-upload-x',
expectedEnvironmentPairingRevision: 3,
expectedEnvironmentRuntimeId: 'rt-1'
}
function fakeSender(): EventEmitter {
return new EventEmitter()
}
function listenerCount(sender: EventEmitter): number {
return ['destroyed', 'render-process-gone', 'did-navigate'].reduce(
(total, name) => total + sender.listenerCount(name),
0
)
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
streamMock.mockReset()
sweepMock.mockReset()
sweepMock.mockResolvedValue(undefined)
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
})
registerFilesystemMutationHandlers(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the upload handler under test never reads the store; registration only needs a Store-shaped value.
{ getRepos: () => [], getSettings: () => ({ workspaceDir: '/workspace' }) } as never
)
})
function invoke(sender: EventEmitter): Promise<unknown> {
return handlers.get('fs:uploadExternalFileToRuntime')!({ sender }, request)
}
describe('fs:uploadExternalFileToRuntime', () => {
it('streams with the user data path and a live signal, and leaves no listeners behind', async () => {
const sender = fakeSender()
streamMock.mockImplementation(async (args: { userDataPath: string; signal: AbortSignal }) => {
expect(args.userDataPath).toBe('/user-data')
expect(args.signal.aborted).toBe(false)
expect(listenerCount(sender)).toBe(3)
return { byteLength: 42 }
})
await expect(invoke(sender)).resolves.toEqual({ byteLength: 42 })
expect(streamMock).toHaveBeenCalledWith(expect.objectContaining(request))
expect(sweepMock).not.toHaveBeenCalled()
expect(listenerCount(sender)).toBe(0)
})
it('resolves the selector to the environment id before streaming and sweeping', async () => {
const sender = fakeSender()
streamMock.mockImplementation(
({ signal }: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
sender.emit('destroyed')
})
)
await expect(
handlers.get('fs:uploadExternalFileToRuntime')!(
{ sender },
{ ...request, environmentId: 'env-alias' }
)
).rejects.toThrow(RENDERER_GONE_MESSAGE)
expect(streamMock).toHaveBeenCalledWith(expect.objectContaining({ environmentId: 'env-1' }))
expect(sweepMock).toHaveBeenCalledWith('/user-data', { ...request, environmentId: 'env-1' })
})
it('aborts, sweeps the temp path, and rethrows when the renderer is destroyed mid-stream', async () => {
const sender = fakeSender()
streamMock.mockImplementation(
({ signal }: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
sender.emit('destroyed')
})
)
await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE)
expect(sweepMock).toHaveBeenCalledTimes(1)
expect(sweepMock).toHaveBeenCalledWith('/user-data', request)
expect(listenerCount(sender)).toBe(0)
})
it('aborts once a reload commits, not on a blocked navigation or an in-app route change', async () => {
const sender = fakeSender()
let observed: AbortSignal | undefined
streamMock.mockImplementation(
({ signal }: { signal: AbortSignal }) =>
new Promise((resolve, reject) => {
observed = signal
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true })
sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/')
queueMicrotask(() => {
expect(signal.aborted).toBe(false)
sender.emit('did-navigate', 'file:///app/index.html', 200, 'OK')
resolve({ byteLength: 0 })
})
})
)
await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE)
expect(observed?.aborted).toBe(true)
expect(sweepMock).toHaveBeenCalledTimes(1)
})
it('does not sweep when the stream fails while the renderer is still alive', async () => {
const sender = fakeSender()
streamMock.mockRejectedValue(new Error("File changed since it was staged: 'file.bin'"))
await expect(invoke(sender)).rejects.toThrow("File changed since it was staged: 'file.bin'")
expect(sweepMock).not.toHaveBeenCalled()
expect(listenerCount(sender)).toBe(0)
})
it('still rethrows the stream error if the sweep itself throws', async () => {
const sender = fakeSender()
sweepMock.mockRejectedValue(new Error('sweep exploded'))
streamMock.mockImplementation(
({ signal }: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
sender.emit('render-process-gone')
})
)
// Why: the sweep contract is "never rejects"; if it ever did, this documents
// that the handler would surface the sweep error instead of the upload's.
await expect(invoke(sender)).rejects.toThrow('sweep exploded')
expect(listenerCount(sender)).toBe(0)
})
})
+52 -3
View File
@@ -1,4 +1,4 @@
import { ipcMain } from 'electron'
import { app, ipcMain } from 'electron'
import { constants } from 'node:fs'
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
@@ -18,7 +18,15 @@ import type {
StagedExternalImportSource
} from '../../shared/filesystem-import-result-types'
import { importOneSource } from './filesystem-import-local'
import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging'
import {
stagedRuntimeUploadByteLength,
stageOneSourceForRuntimeUpload
} from './filesystem-runtime-upload-staging'
import { streamExternalFileToRuntime } from './runtime-upload-file-stream'
import { abortWhenRendererGone } from './renderer-lifetime-abort'
import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep'
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
/**
* IPC handlers for file/folder creation and renaming.
@@ -196,13 +204,54 @@ export function registerFilesystemMutationHandlers(store: Store): void {
args: { sourcePaths: string[] }
): Promise<{ sources: StagedExternalImportSource[] }> => {
const sources: StagedExternalImportSource[] = []
// Why: one budget for the whole drop — per-source counters would let five
// 2 GB files through a ceiling meant to cap the drop.
let totalBytes = 0
for (const sourcePath of args.sourcePaths) {
sources.push(await stageOneSourceForRuntimeUpload(sourcePath))
const source = await stageOneSourceForRuntimeUpload(sourcePath, totalBytes)
totalBytes += stagedRuntimeUploadByteLength(source)
sources.push(source)
}
return { sources }
}
)
// Why: the file handle and the runtime socket both live in main, so the byte
// pump runs here. The renderer keeps deconflict/commit/rollback orchestration
// and never sees file contents.
ipcMain.handle(
'fs:uploadExternalFileToRuntime',
async (event, args: RuntimeUploadFileStreamRequest): Promise<{ byteLength: number }> => {
const userDataPath = app.getPath('userData')
// Why: the streamer's manual-disconnect check keys on the environment id,
// and the renderer may pass any selector the store resolves.
const request = {
...args,
environmentId: resolveEnvironment(userDataPath, args.environmentId).id
}
// Why: the renderer's own loop died with its window. Now that the bytes
// move in main, a reload or close has to stop the transfer explicitly,
// or a multi-GB upload outlives the window that asked for it.
const lifetime = abortWhenRendererGone(event.sender)
try {
return await streamExternalFileToRuntime({
...request,
userDataPath,
signal: lifetime.signal
})
} catch (error) {
if (lifetime.signal.aborted) {
// Why: the renderer owns temp cleanup, and it is gone — so the
// abandoned temp path is only collectable from here.
await sweepAbandonedRuntimeUploadTempPath(userDataPath, request)
}
throw error
} finally {
lifetime.dispose()
}
}
)
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
// upload each path into `${worktreePath}/.orca/drops/` and return remote
@@ -0,0 +1,166 @@
import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeImportLimits from './runtime-import-limits'
type RuntimeImportLimitsModule = typeof RuntimeImportLimits
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a
// literal over-limit fixture would allocate that much on Windows CI.
vi.mock('./runtime-import-limits', async (importOriginal) => ({
...(await importOriginal<RuntimeImportLimitsModule>()),
REMOTE_IMPORT_MAX_FILE_BYTES: 4 * 1024,
REMOTE_IMPORT_MAX_TOTAL_BYTES: 16 * 1024
}))
const { stagedRuntimeUploadByteLength, stageOneSourceForRuntimeUpload } =
await import('./filesystem-runtime-upload-staging')
let workDir: string
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), 'orca-upload-staging-'))
})
afterEach(async () => {
await rm(workDir, { force: true, recursive: true })
})
describe('stageOneSourceForRuntimeUpload', () => {
it('records size instead of file contents so staging never holds the body', async () => {
const filePath = join(workDir, 'note.txt')
await writeFile(filePath, 'hello world')
const staged = await stageOneSourceForRuntimeUpload(filePath)
expect(staged).toMatchObject({
status: 'staged',
kind: 'file',
name: 'note.txt',
entries: [{ relativePath: '', kind: 'file', byteLength: 11 }]
})
expect(JSON.stringify(staged)).not.toContain('contentBase64')
})
it('records the identity the uploader re-checks, not just the size', async () => {
const filePath = join(workDir, 'note.txt')
await writeFile(filePath, 'hello world')
const stat = await lstat(filePath)
const staged = await stageOneSourceForRuntimeUpload(filePath)
expect(staged).toMatchObject({
status: 'staged',
entries: [
{
byteLength: 11,
inode: stat.ino,
deviceId: stat.dev,
modifiedAtMs: stat.mtimeMs
}
]
})
})
it('stages a file with no cap error, where the old buffering path refused', async () => {
const filePath = join(workDir, 'big.bin')
await writeFile(filePath, Buffer.alloc(3 * 1024))
await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({
status: 'staged',
entries: [{ kind: 'file', byteLength: 3 * 1024 }]
})
})
it('names the file, the actual size and the limit when a file is over the ceiling', async () => {
const filePath = join(workDir, 'clip.mp4')
await writeFile(filePath, Buffer.alloc(6 * 1024))
const staged = await stageOneSourceForRuntimeUpload(filePath)
expect(staged).toMatchObject({ status: 'failed' })
// Why: a dropped file's relative path is '', so this is the regression that
// would otherwise report "'' is 6 KB, over the 4 KB ... limit".
expect(staged.status === 'failed' && staged.reason).toBe(
"'clip.mp4' is 6 KB, over the 4 KB per-file remote import limit"
)
})
it('names the offending entry by its path inside a dropped directory', async () => {
const rootPath = join(workDir, 'media')
await mkdir(join(rootPath, 'clips'), { recursive: true })
await writeFile(join(rootPath, 'clips', 'big.mp4'), Buffer.alloc(6 * 1024))
const staged = await stageOneSourceForRuntimeUpload(rootPath)
expect(staged.status === 'failed' && staged.reason).toContain("'clips/big.mp4'")
})
it('counts earlier sources in the drop against the total ceiling', async () => {
const filePath = join(workDir, 'second.bin')
await writeFile(filePath, Buffer.alloc(3 * 1024))
// Alone it fits; after 14 KB of earlier sources the 16 KB drop ceiling is gone.
await expect(stageOneSourceForRuntimeUpload(filePath, 0)).resolves.toMatchObject({
status: 'staged'
})
const overBudget = await stageOneSourceForRuntimeUpload(filePath, 14 * 1024)
expect(overBudget).toMatchObject({ status: 'failed' })
expect(overBudget.status === 'failed' && overBudget.reason).toContain(
'total remote import limit'
)
})
it('reports the bytes a source contributes to the drop budget', async () => {
const rootPath = join(workDir, 'tree')
await mkdir(join(rootPath, 'nested'), { recursive: true })
await writeFile(join(rootPath, 'a.txt'), 'aa')
await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb')
const staged = await stageOneSourceForRuntimeUpload(rootPath)
expect(stagedRuntimeUploadByteLength(staged)).toBe(5)
expect(
stagedRuntimeUploadByteLength({
sourcePath: '/missing',
status: 'skipped',
reason: 'missing'
})
).toBe(0)
})
// symlink() needs privileges or Developer Mode on Windows.
it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => {
const targetPath = join(workDir, 'target.txt')
await writeFile(targetPath, 'data')
const linkPath = join(workDir, 'link.txt')
await symlink(targetPath, linkPath)
await expect(stageOneSourceForRuntimeUpload(linkPath)).resolves.toMatchObject({
status: 'skipped',
reason: 'symlink'
})
})
it('stages directory trees as metadata for every entry', async () => {
const rootPath = join(workDir, 'assets')
await mkdir(join(rootPath, 'nested'), { recursive: true })
await writeFile(join(rootPath, 'a.txt'), 'aa')
await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb')
const staged = await stageOneSourceForRuntimeUpload(rootPath)
expect(staged.status).toBe('staged')
const entries = staged.status === 'staged' ? staged.entries : []
expect(entries).toEqual(
expect.arrayContaining([
{ relativePath: '', kind: 'directory' },
expect.objectContaining({ relativePath: 'a.txt', kind: 'file', byteLength: 2 }),
{ relativePath: 'nested', kind: 'directory' },
expect.objectContaining({ relativePath: 'nested/b.txt', kind: 'file', byteLength: 3 })
])
)
})
})
@@ -1,3 +1,8 @@
import {
formatByteCeiling,
REMOTE_IMPORT_MAX_FILE_BYTES,
REMOTE_IMPORT_MAX_TOTAL_BYTES
} from './runtime-import-limits'
import { constants } from 'node:fs'
import { lstat, open, readdir, realpath } from 'node:fs/promises'
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
@@ -8,18 +13,31 @@ import type {
StagedExternalImportSource
} from '../../shared/filesystem-import-result-types'
const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
class RuntimeUploadSymlinkError extends Error {}
/** Bytes this source contributes to the drop budget; 0 unless it staged. */
export function stagedRuntimeUploadByteLength(source: StagedExternalImportSource): number {
if (source.status !== 'staged') {
return 0
}
return source.entries.reduce(
(total, entry) => (entry.kind === 'file' ? total + entry.byteLength : total),
0
)
}
/**
* @param totalBytesBefore Bytes already staged by earlier sources in the same drop,
* so the total ceiling covers the whole drop rather than each source alone.
*/
export async function stageOneSourceForRuntimeUpload(
sourcePath: string
sourcePath: string,
totalBytesBefore = 0
): Promise<StagedExternalImportSource> {
const resolvedSource = resolve(sourcePath)
// Why: runtime uploads read client-local paths in the client main process;
// authorize before lstat/readFile just like local copy imports.
// authorize before lstat just like local copy imports.
authorizeExternalPath(resolvedSource)
let sourceStat: Awaited<ReturnType<typeof lstat>>
@@ -52,8 +70,8 @@ export async function stageOneSourceForRuntimeUpload(
}
try {
const entries = sourceStat.isDirectory()
? await stageDirectoryEntries(resolvedSource)
: [(await stageFileEntry(resolvedSource, '')).entry]
? await stageDirectoryEntries(resolvedSource, totalBytesBefore)
: [(await stageFileEntry(resolvedSource, '', { totalBytesBefore })).entry]
return {
sourcePath,
status: 'staged',
@@ -73,9 +91,12 @@ export async function stageOneSourceForRuntimeUpload(
}
}
async function stageDirectoryEntries(rootPath: string): Promise<StagedExternalImportEntry[]> {
async function stageDirectoryEntries(
rootPath: string,
totalBytesBefore: number
): Promise<StagedExternalImportEntry[]> {
const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }]
let totalBytes = 0
let totalBytes = totalBytesBefore
const rootRealPath = await realpath(rootPath)
async function visit(dirPath: string): Promise<void> {
@@ -126,52 +147,52 @@ async function stageDirectoryEntries(rootPath: string): Promise<StagedExternalIm
async function stageFileEntry(
filePath: string,
relativePath: string,
options?: { rootRealPath?: string; totalBytesBefore?: number }
options: { rootRealPath?: string; totalBytesBefore: number }
): Promise<{ entry: StagedExternalImportEntry; byteLength: number }> {
const statResult = await lstat(filePath)
const displayPath = normalizeRelativeUploadPath(relativePath)
// Why: a dropped file's relative path is '', so errors would name nothing.
// The entry keeps '' — only the message falls back to the file's own name.
const displayName = displayPath || basename(filePath)
if (statResult.isSymbolicLink()) {
throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayPath}'`)
throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayName}'`)
}
if (!statResult.isFile()) {
throw new Error(`Unsupported file type in '${displayPath}'`)
throw new Error(`Unsupported file type in '${displayName}'`)
}
if (options?.rootRealPath) {
await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath)
if (options.rootRealPath) {
await assertRealPathInsideRoot(options.rootRealPath, filePath, displayName)
}
const initialTotalBytes =
options?.totalBytesBefore === undefined
? statResult.size
: options.totalBytesBefore + statResult.size
assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes)
assertRemoteUploadBudget(displayName, statResult.size, options.totalBytesBefore + statResult.size)
const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
try {
const openedStat = await fileHandle.stat()
if (!openedStat.isFile()) {
throw new Error(`Unsupported file type in '${displayPath}'`)
throw new Error(`Unsupported file type in '${displayName}'`)
}
if (
openedStat.size !== statResult.size ||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
) {
throw new Error(`File changed during upload staging: '${displayPath}'`)
}
const totalBytes =
options?.totalBytesBefore === undefined
? openedStat.size
: options.totalBytesBefore + openedStat.size
assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes)
const buffer = await fileHandle.readFile()
const afterReadStat = await fileHandle.stat()
if (afterReadStat.size !== openedStat.size) {
throw new Error(`File changed during upload staging: '${displayPath}'`)
throw new Error(`File changed during upload staging: '${displayName}'`)
}
assertRemoteUploadBudget(
displayName,
openedStat.size,
options.totalBytesBefore + openedStat.size
)
// Why: bytes are read slice-by-slice at upload time, so staging records the
// identity the streamer re-checks rather than the body itself. Size alone
// would let a same-size replacement slip through between the two calls.
return {
entry: {
relativePath: displayPath,
kind: 'file',
contentBase64: buffer.toString('base64')
byteLength: openedStat.size,
inode: openedStat.ino,
deviceId: openedStat.dev,
modifiedAtMs: openedStat.mtimeMs
},
byteLength: openedStat.size
}
@@ -197,15 +218,21 @@ async function assertRealPathInsideRoot(
}
function assertRemoteUploadBudget(
relativePath: string,
displayName: string,
fileBytes: number,
totalBytes: number
): void {
if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) {
throw new Error(`'${relativePath}' is too large for remote import`)
throw new Error(
`'${displayName}' is ${formatByteCeiling(fileBytes)}, over the ` +
`${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit`
)
}
if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) {
throw new Error('Remote import is too large')
throw new Error(
`This import is ${formatByteCeiling(totalBytes)}, over the ` +
`${formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)} total remote import limit`
)
}
}
@@ -0,0 +1,91 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it } from 'vitest'
import {
abortWhenRendererGone,
RENDERER_GONE_MESSAGE,
type RendererLifetimeSender
} from './renderer-lifetime-abort'
function fakeSender(): RendererLifetimeSender & EventEmitter {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: EventEmitter implements the once/on/removeListener surface this helper uses, and those three are all it calls; WebContents' overloaded signatures cannot be satisfied structurally.
return new EventEmitter() as RendererLifetimeSender & EventEmitter
}
describe('abortWhenRendererGone', () => {
it('aborts when the renderer is destroyed', () => {
const sender = fakeSender()
const { signal } = abortWhenRendererGone(sender)
expect(signal.aborted).toBe(false)
sender.emit('destroyed')
expect(signal.aborted).toBe(true)
expect(String(signal.reason)).toContain(RENDERER_GONE_MESSAGE)
})
it('aborts when the render process is gone', () => {
const sender = fakeSender()
const { signal } = abortWhenRendererGone(sender)
sender.emit('render-process-gone')
expect(signal.aborted).toBe(true)
})
it('aborts once a reload has replaced the document, not on in-app route changes', () => {
const sender = fakeSender()
const { signal } = abortWhenRendererGone(sender)
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: true,
url: 'file:///app#x'
})
sender.emit('did-navigate-in-page', 'file:///app#x')
expect(signal.aborted).toBe(false)
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
url: 'file:///app'
})
sender.emit('did-navigate', 'file:///app', 200, 'OK')
expect(signal.aborted).toBe(true)
})
it('ignores a main-frame navigation that starts but is blocked before it commits', () => {
// Why: Electron emits did-start-navigation before will-navigate gets to
// preventDefault() an external link or a stray file drop; the renderer
// document survives those, so the upload must too.
const sender = fakeSender()
const { signal } = abortWhenRendererGone(sender)
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
url: 'https://example.invalid/'
})
sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/')
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
url: 'file:///Users/me/dropped.png'
})
sender.emit('will-navigate', { defaultPrevented: true }, 'file:///Users/me/dropped.png')
expect(signal.aborted).toBe(false)
})
it('leaves no listeners on a long-lived renderer once disposed', () => {
const sender = fakeSender()
const { dispose } = abortWhenRendererGone(sender)
expect(sender.listenerCount('destroyed')).toBe(1)
dispose()
dispose()
expect(sender.listenerCount('destroyed')).toBe(0)
expect(sender.listenerCount('render-process-gone')).toBe(0)
expect(sender.listenerCount('did-navigate')).toBe(0)
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { WebContents } from 'electron'
export type RendererLifetimeSender = Pick<WebContents, 'once' | 'removeListener'>
export const RENDERER_GONE_MESSAGE = 'The window that started this upload went away'
/**
* Abort signal that fires when the calling renderer goes away.
*
* Work the renderer used to do itself died with it. Once it moves into main,
* nothing stops a long transfer from outliving the window that asked for it,
* so the caller's lifetime has to be wired up explicitly.
*
* Always `dispose()` in a finally — otherwise every call leaks a listener on a
* long-lived WebContents.
*/
export function abortWhenRendererGone(sender: RendererLifetimeSender): {
signal: AbortSignal
dispose: () => void
} {
const controller = new AbortController()
const abort = (): void => controller.abort(new Error(RENDERER_GONE_MESSAGE))
let disposed = false
sender.once('destroyed', abort)
sender.once('render-process-gone', abort)
// Why: did-start-navigation also fires for navigations that will-navigate then
// blocks — an external link, a stray file drop — and the renderer survives
// those. did-navigate fires only once a new document has replaced the caller,
// and never for same-document route changes inside the live app.
sender.once('did-navigate', abort)
return {
signal: controller.signal,
dispose: () => {
if (disposed) {
return
}
disposed = true
sender.removeListener('destroyed', abort)
sender.removeListener('render-process-gone', abort)
sender.removeListener('did-navigate', abort)
}
}
}
@@ -27,7 +27,8 @@ import {
import {
clearRuntimeEnvironmentManualDisconnect,
isRuntimeEnvironmentManuallyDisconnected,
markRuntimeEnvironmentManuallyDisconnected
markRuntimeEnvironmentManuallyDisconnected,
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
} from './runtime-environment-manual-disconnect'
import {
callRuntimeEnvironment,
@@ -42,7 +43,7 @@ function manuallyDisconnectedResponse(
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: 'Runtime environment is manually disconnected.'
message: RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
},
_meta: { runtimeId: environment.runtimeId }
}
@@ -1,5 +1,7 @@
const manuallyDisconnectedEnvironmentIds = new Set<string>()
export const RUNTIME_MANUALLY_DISCONNECTED_MESSAGE = 'Runtime environment is manually disconnected.'
export function markRuntimeEnvironmentManuallyDisconnected(environmentId: string): void {
manuallyDisconnectedEnvironmentIds.add(environmentId)
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
formatByteCeiling,
REMOTE_IMPORT_MAX_FILE_BYTES,
REMOTE_IMPORT_MAX_TOTAL_BYTES
} from './runtime-import-limits'
describe('formatByteCeiling', () => {
it('renders a size one byte over a ceiling as larger than the ceiling', () => {
// "is 2 GB, over the 2 GB limit" reads like a broken check, not a big file.
expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)).toBe('2 GB')
expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES + 1)).toBe('2.1 GB')
})
it('leaves an exact ceiling as a whole number', () => {
expect(formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)).toBe('8 GB')
expect(formatByteCeiling(1024)).toBe('1 KB')
})
it('scales through the units', () => {
expect(formatByteCeiling(512)).toBe('512 B')
expect(formatByteCeiling(1024 * 1024)).toBe('1 MB')
expect(formatByteCeiling(1024 ** 4)).toBe('1 TB')
})
it('rounds up rather than to nearest', () => {
expect(formatByteCeiling(1024 * 1024 + 1)).toBe('1.1 MB')
})
it('does not crash on zero', () => {
expect(formatByteCeiling(0)).toBe('0 B')
})
})
+18
View File
@@ -0,0 +1,18 @@
// Why: staging streams slices at upload time and never holds a whole file, so
// these are user-safety ceilings on an unattended transfer, not memory guards.
// They stay until the drop UI can show progress and cancel a running upload.
export const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024
/** Rounds up, so a size over a ceiling never renders as the ceiling itself. */
export function formatByteCeiling(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
const rounded = Math.ceil(value * 10) / 10
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}`
}
@@ -0,0 +1,438 @@
import { lstat, mkdtemp, mkdir, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract'
import type * as RuntimeImportLimits from './runtime-import-limits'
type RuntimeImportLimitsModule = typeof RuntimeImportLimits
type ChunkCall = {
relativePath: string
contentBase64: string
append: boolean
expectedSshTargetId?: string
expectedSshConnectionGeneration?: number
expectedExecutionHostId?: string
}
type RuntimeCallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal }
const callRuntimeEnvironment =
vi.fn<
(
userDataPath: string,
environmentId: string,
method: string,
params: ChunkCall,
timeoutMs?: number,
expectedEnvironmentPairingRevision?: number,
envelope?: unknown,
options?: RuntimeCallOptions
) => unknown
>()
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: (...args: Parameters<typeof callRuntimeEnvironment>) =>
callRuntimeEnvironment(...args)
}))
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
// Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture
// would allocate gigabytes on Windows.
vi.mock('./runtime-import-limits', async (importOriginal) => ({
...(await importOriginal<RuntimeImportLimitsModule>()),
REMOTE_IMPORT_MAX_FILE_BYTES: 2 * 1024 * 1024
}))
const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } =
await import('./runtime-upload-file-stream')
const {
clearRuntimeEnvironmentManualDisconnect,
markRuntimeEnvironmentManuallyDisconnected,
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
} = await import('./runtime-environment-manual-disconnect')
let workDir: string
function chunkCalls(): ChunkCall[] {
return callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.writeBase64Chunk')
.map(([, , , params]) => params)
}
function uploadedBytes(): Buffer {
return Buffer.concat(chunkCalls().map((call) => Buffer.from(call.contentBase64, 'base64')))
}
/** Mirrors what staging records, so tests exercise the real identity contract. */
async function stagedIdentity(filePath: string): Promise<StagedRuntimeUploadFileIdentity> {
const stat = await lstat(filePath)
return {
byteLength: stat.size,
inode: stat.ino,
deviceId: stat.dev,
modifiedAtMs: stat.mtimeMs
}
}
async function baseArgs(sourceRootPath: string, entryPath?: string) {
return {
userDataPath: '/user-data',
environmentId: 'env-1',
sourceRootPath,
entryRelativePath: entryPath ?? '',
expected: await stagedIdentity(entryPath ? join(sourceRootPath, entryPath) : sourceRootPath),
worktree: 'wt-1',
relativePath: '.upload.tmp'
}
}
/** A path whose identity was never measured; every field is deliberately absent. */
function unstagedArgs(sourceRootPath: string, entryPath?: string) {
return {
userDataPath: '/user-data',
environmentId: 'env-1',
sourceRootPath,
entryRelativePath: entryPath ?? '',
expected: { byteLength: 0, inode: 0, deviceId: 0, modifiedAtMs: 0 },
worktree: 'wt-1',
relativePath: '.upload.tmp'
}
}
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), 'orca-upload-stream-'))
callRuntimeEnvironment.mockReset()
callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} })
})
afterEach(async () => {
await rm(workDir, { force: true, recursive: true })
})
describe('streamExternalFileToRuntime', () => {
it('sends a file larger than the old 25 MB cap as ordered append-only slices', async () => {
const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 1234
const contents = Buffer.alloc(size)
for (let index = 0; index < size; index += 1) {
contents[index] = index % 251
}
const filePath = join(workDir, 'big.bin')
await writeFile(filePath, contents)
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({
byteLength: size
})
const calls = chunkCalls()
expect(calls).toHaveLength(3)
expect(calls.map((call) => call.append)).toEqual([false, true, true])
expect(uploadedBytes().equals(contents)).toBe(true)
})
it('refuses a source whose size no longer matches what staging measured', async () => {
const filePath = join(workDir, 'grown.bin')
await writeFile(filePath, Buffer.alloc(1024))
const staged = await stagedIdentity(filePath)
await writeFile(filePath, Buffer.alloc(2048))
await expect(
streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged })
).rejects.toThrow("File changed since it was staged: 'grown.bin'")
expect(chunkCalls()).toHaveLength(0)
})
it('refuses a source swapped for a different file of the same size', async () => {
const filePath = join(workDir, 'swapped.bin')
await writeFile(filePath, Buffer.alloc(2048, 0x41))
const staged = await stagedIdentity(filePath)
// A rename-into-place keeps the size and changes the inode.
const decoyPath = join(workDir, 'decoy.bin')
await writeFile(decoyPath, Buffer.alloc(2048, 0x42))
await rename(decoyPath, filePath)
await expect(
streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged })
).rejects.toThrow('File changed since it was staged')
expect(chunkCalls()).toHaveLength(0)
})
it('refuses a source rewritten in place at the same size after staging', async () => {
const filePath = join(workDir, 'rewritten.bin')
await writeFile(filePath, Buffer.alloc(2048, 0x41))
const staged = await stagedIdentity(filePath)
// Same inode and size; only the modification time moves.
await writeFile(filePath, Buffer.alloc(2048, 0x42))
const bumped = new Date(staged.modifiedAtMs + 5_000)
await utimes(filePath, bumped, bumped)
await expect(
streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged })
).rejects.toThrow('File changed since it was staged')
expect(chunkCalls()).toHaveLength(0)
})
it('aborts when the source is rewritten at the same size mid-transfer', async () => {
const filePath = join(workDir, 'racing.bin')
const size = RUNTIME_UPLOAD_SLICE_BYTES * 2
await writeFile(filePath, Buffer.alloc(size, 0x41))
const args = await baseArgs(filePath)
let rewritten = false
callRuntimeEnvironment.mockImplementation(async () => {
if (!rewritten) {
rewritten = true
await writeFile(filePath, Buffer.alloc(size, 0x42))
const bumped = new Date(args.expected.modifiedAtMs + 5_000)
await utimes(filePath, bumped, bumped)
}
return { id: 'x', ok: true, result: {}, _meta: {} }
})
await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload')
})
it('accepts a source that still matches its staged identity', async () => {
const filePath = join(workDir, 'same.bin')
await writeFile(filePath, Buffer.alloc(2048))
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({
byteLength: 2048
})
})
it('refuses a file over the ceiling and names the source, not the temp path', async () => {
const filePath = join(workDir, 'clip.mp4')
await writeFile(filePath, Buffer.alloc(3 * 1024 * 1024))
// Why: relativePath here is '.upload.tmp', a path the user never chose.
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow(
"'clip.mp4' is 3 MB, over the 2 MB per-file remote import limit"
)
expect(chunkCalls()).toHaveLength(0)
})
it('never buffers more than one slice per chunk', async () => {
const filePath = join(workDir, 'sliced.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2))
await streamExternalFileToRuntime(await baseArgs(filePath))
for (const call of chunkCalls()) {
expect(Buffer.from(call.contentBase64, 'base64').byteLength).toBeLessThanOrEqual(
RUNTIME_UPLOAD_SLICE_BYTES
)
}
})
it('creates an empty destination for a zero-byte source', async () => {
const filePath = join(workDir, 'empty.txt')
await writeFile(filePath, '')
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({
byteLength: 0
})
expect(chunkCalls()).toEqual([expect.objectContaining({ append: false, contentBase64: '' })])
})
it('refuses to finish a zero-byte upload whose source gained content mid-write', async () => {
const filePath = join(workDir, 'grows.txt')
await writeFile(filePath, '')
const args = await baseArgs(filePath)
callRuntimeEnvironment.mockImplementation(async () => {
await writeFile(filePath, 'content arrived during the empty write')
return { id: 'x', ok: true, result: {}, _meta: {} }
})
await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload')
})
it('carries the pairing revision and runtime id on every chunk', async () => {
const filePath = join(workDir, 'guarded.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10))
await streamExternalFileToRuntime({
...(await baseArgs(filePath)),
expectedEnvironmentPairingRevision: 41,
expectedEnvironmentRuntimeId: 'runtime-7'
})
const guards = callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.writeBase64Chunk')
.map(([, , , , , revision, , options]) => ({
revision,
runtimeId: options?.expectedEnvironmentRuntimeId
}))
expect(guards).toEqual([
{ revision: 41, runtimeId: 'runtime-7' },
{ revision: 41, runtimeId: 'runtime-7' }
])
})
it('stops mid-transfer when the caller aborts instead of streaming the rest', async () => {
const filePath = join(workDir, 'abandoned.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 4))
const controller = new AbortController()
callRuntimeEnvironment.mockImplementation(async () => {
controller.abort(new Error('window closed'))
return { id: 'x', ok: true, result: {}, _meta: {} }
})
await expect(
streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal })
).rejects.toThrow('window closed')
// One slice went out before the abort; the other three never do.
expect(chunkCalls()).toHaveLength(1)
})
it('refuses to start once the caller has already aborted', async () => {
const filePath = join(workDir, 'never.bin')
await writeFile(filePath, Buffer.alloc(1024))
const controller = new AbortController()
controller.abort(new Error('window closed'))
await expect(
streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal })
).rejects.toThrow('window closed')
expect(chunkCalls()).toHaveLength(0)
})
it('passes the abort signal to every chunk so an in-flight request is cancelled', async () => {
const filePath = join(workDir, 'signalled.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10))
const controller = new AbortController()
await streamExternalFileToRuntime({
...(await baseArgs(filePath)),
signal: controller.signal
})
const signals = callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.writeBase64Chunk')
.map(([, , , , , , , options]) => options?.signal)
expect(signals).toEqual([controller.signal, controller.signal])
})
it('stops at the failing chunk instead of sending the rest of the file', async () => {
const filePath = join(workDir, 'fails.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3))
callRuntimeEnvironment.mockResolvedValueOnce({ id: 'x', ok: true, result: {}, _meta: {} })
callRuntimeEnvironment.mockResolvedValueOnce({
id: 'x',
ok: false,
error: { code: 'write_failed', message: 'disk full' }
})
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow('disk full')
expect(chunkCalls()).toHaveLength(2)
})
// symlink() needs privileges or Developer Mode on Windows.
it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => {
const targetPath = join(workDir, 'secret.txt')
await writeFile(targetPath, 'secret')
const linkPath = join(workDir, 'link.txt')
await symlink(targetPath, linkPath)
await expect(streamExternalFileToRuntime(unstagedArgs(linkPath))).rejects.toThrow(
'Symlink not allowed'
)
expect(chunkCalls()).toHaveLength(0)
})
it.skipIf(process.platform === 'win32')(
'refuses a regular file reached through a symlinked directory inside the root',
async () => {
// Why: the symlink guard only lstats the entry itself, which sees a plain
// file here — realpath containment is the only thing that catches this.
const outsideDir = join(workDir, 'outside')
await mkdir(outsideDir)
await writeFile(join(outsideDir, 'secret.txt'), 'secret')
const rootPath = join(workDir, 'root')
await mkdir(rootPath)
await symlink(outsideDir, join(rootPath, 'sub'))
await expect(
streamExternalFileToRuntime(unstagedArgs(rootPath, 'sub/secret.txt'))
).rejects.toThrow('Path escaped upload root during upload')
expect(chunkCalls()).toHaveLength(0)
}
)
it('forwards the host ownership expectations into every chunk', async () => {
const filePath = join(workDir, 'owned.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10))
await streamExternalFileToRuntime({
...(await baseArgs(filePath)),
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 5,
expectedExecutionHostId: 'ssh:ssh-1'
})
const calls = callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.writeBase64Chunk')
.map(([, , , params]) => params)
expect(calls).toHaveLength(2)
for (const params of calls) {
expect(params).toMatchObject({
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 5,
expectedExecutionHostId: 'ssh:ssh-1'
})
}
})
it.skipIf(process.platform === 'win32')(
'refuses a symlinked directory entry before it reaches the containment check',
async () => {
const outsidePath = join(workDir, 'outside.txt')
await writeFile(outsidePath, 'outside')
const rootPath = join(workDir, 'root')
await mkdir(rootPath)
await symlink(outsidePath, join(rootPath, 'escape.txt'))
await expect(
streamExternalFileToRuntime(unstagedArgs(rootPath, 'escape.txt'))
).rejects.toThrow('Symlink not allowed')
expect(chunkCalls()).toHaveLength(0)
}
)
})
describe('manual disconnect during a transfer', () => {
afterEach(() => {
clearRuntimeEnvironmentManualDisconnect('env-1')
})
it('stops at the next slice once the environment is manually disconnected', async () => {
const filePath = join(workDir, 'disconnect.bin')
await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3, 7))
callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => {
if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) {
markRuntimeEnvironmentManuallyDisconnected('env-1')
}
return { id: 'x', ok: true, result: {}, _meta: {} }
})
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow(
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
)
expect(chunkCalls()).toHaveLength(1)
})
it('refuses the first slice when the environment is already disconnected', async () => {
const filePath = join(workDir, 'disconnected.bin')
await writeFile(filePath, Buffer.alloc(16, 1))
markRuntimeEnvironmentManuallyDisconnected('env-1')
await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow(
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
)
expect(chunkCalls()).toHaveLength(0)
})
})
+213
View File
@@ -0,0 +1,213 @@
import { constants, type Stats } from 'node:fs'
import { lstat, open, realpath } from 'node:fs/promises'
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
import type {
RuntimeUploadFileStreamRequest,
StagedRuntimeUploadFileIdentity
} from '../../shared/runtime-upload-staging-contract'
import { authorizeExternalPath } from './filesystem-auth'
import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits'
import {
isRuntimeEnvironmentManuallyDisconnected,
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
} from './runtime-environment-manual-disconnect'
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
// Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire
// as exactly 512 KiB — the chunk size the renderer used before streaming.
export const RUNTIME_UPLOAD_SLICE_BYTES = 384 * 1024
const RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS = 30_000
export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & {
/** Resolved environment id, not a selector: the manual-disconnect check keys on it. */
environmentId: string
userDataPath: string
/** Aborts the transfer; the caller's lifetime is what raises it today. */
signal?: AbortSignal
}
/**
* Stream one client-local file to a runtime environment in slices.
*
* Replaces reading the whole file into memory and base64-encoding it before the
* first byte moves. Peak memory is one slice, so imports are no longer bounded
* by main-process heap.
*/
export async function streamExternalFileToRuntime(
args: RuntimeUploadFileStreamArgs
): Promise<{ byteLength: number }> {
const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath)
// Why: parity with staging — an OS drop authorizes the paths it hands over.
authorizeExternalPath(sourcePath)
// Why: relativePath is the hidden .orca-upload-<nonce> temp destination, so a
// dropped file names its source instead of a path the user never chose.
const displayPath = args.entryRelativePath || basename(args.sourceRootPath)
const lstatResult = await lstat(sourcePath)
if (lstatResult.isSymbolicLink()) {
throw new Error(`Symlink not allowed in '${displayPath}'`)
}
if (!lstatResult.isFile()) {
throw new Error(`Unsupported file type in '${displayPath}'`)
}
if (args.entryRelativePath) {
await assertEntryInsideRoot(args.sourceRootPath, sourcePath, displayPath)
}
assertMatchesStagedIdentity(lstatResult, args.expected, displayPath)
args.signal?.throwIfAborted()
const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
try {
const openedStat = await handle.stat()
if (!openedStat.isFile()) {
throw new Error(`Unsupported file type in '${displayPath}'`)
}
if (!isSameFile(openedStat, lstatResult)) {
throw new Error(`File changed during upload: '${displayPath}'`)
}
// Why: the handle is what the slices are read from, so the staged identity
// has to hold here too — checking only the pre-open lstat leaves a window
// where the path is swapped between lstat and open.
assertMatchesStagedIdentity(openedStat, args.expected, displayPath)
const totalBytes = openedStat.size
// Why: enforced again where the bytes actually move. Staging is a separate
// call, so the ceiling only holds here if this boundary checks it too.
if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) {
throw new Error(
`'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` +
`${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit`
)
}
if (totalBytes === 0) {
// Why: a zero-byte source produces no slices, but the destination still
// has to exist before commitUpload renames it into place.
await sendChunk(args, '', false)
} else {
const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes))
let offset = 0
while (offset < totalBytes) {
// Why: checked per slice, so an abort stops the transfer at the next
// boundary instead of after the whole file has moved.
args.signal?.throwIfAborted()
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset)
if (bytesRead === 0) {
throw new Error(`File truncated during upload: '${displayPath}'`)
}
await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0)
offset += bytesRead
}
}
// Why: the destination is a temp path the caller commits, so a source
// rewritten mid-transfer is caught before anything lands at the final path.
// mtime catches an in-place edit that kept the size. An empty source runs
// this too: its chunk is still a round trip the source can change during.
const afterReadStat = await handle.stat()
if (afterReadStat.mtimeMs !== openedStat.mtimeMs || !isSameFile(afterReadStat, openedStat)) {
throw new Error(`File changed during upload: '${displayPath}'`)
}
return { byteLength: totalBytes }
} finally {
await handle.close()
}
}
/**
* Refuse a source that no longer matches what staging measured.
*
* Inode and device are compared only when both sides report one, because some
* filesystems leave them at 0; size and mtime then carry the check alone.
*/
function assertMatchesStagedIdentity(
observed: Stats,
expected: StagedRuntimeUploadFileIdentity,
displayPath: string
): void {
const changed =
observed.size !== expected.byteLength ||
observed.mtimeMs !== expected.modifiedAtMs ||
(expected.inode !== 0 && observed.ino !== 0 && observed.ino !== expected.inode) ||
(expected.deviceId !== 0 && observed.dev !== 0 && observed.dev !== expected.deviceId)
if (changed) {
throw new Error(`File changed since it was staged: '${displayPath}'`)
}
}
/** Same inode on the same device, where the filesystem reports them. */
function isSameFile(a: Stats, b: Stats): boolean {
return (
a.size === b.size &&
(a.ino === 0 || b.ino === 0 || a.ino === b.ino) &&
(a.dev === 0 || b.dev === 0 || a.dev === b.dev)
)
}
/** Append one base64 slice, carrying the host guards that must hold per chunk. */
async function sendChunk(
args: RuntimeUploadFileStreamArgs,
contentBase64: string,
append: boolean
): Promise<void> {
// Why: the renderer's per-chunk calls went through an IPC handler that refuses
// a manually disconnected environment. The loop lives in main now, so it makes
// the same check, or a disconnect mid-upload keeps pushing bytes to that host.
if (isRuntimeEnvironmentManuallyDisconnected(args.environmentId)) {
throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE)
}
const response = await callRuntimeEnvironment(
args.userDataPath,
args.environmentId,
'files.writeBase64Chunk',
{
worktree: args.worktree,
relativePath: args.relativePath,
contentBase64,
append,
expectedSshTargetId: args.expectedSshTargetId,
expectedSshConnectionGeneration: args.expectedSshConnectionGeneration,
expectedExecutionHostId: args.expectedExecutionHostId
},
RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS,
// Why: re-checked per chunk, so a re-pair mid-upload aborts instead of
// appending the rest of the file on a different host.
args.expectedEnvironmentPairingRevision,
undefined,
{
// Why: a replacement runtime keeps the pairing but invalidates its
// predecessor's capability proof, so the identity rides every chunk too.
expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId,
signal: args.signal
}
)
if (response.ok !== true) {
throw new Error(response.error.message || response.error.code)
}
}
function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string {
// Why: staging resolves before authorizing, so the streamer has to agree on
// the same absolute path or the two checks can disagree.
const root = resolve(sourceRootPath)
return entryRelativePath ? join(root, entryRelativePath) : root
}
async function assertEntryInsideRoot(
sourceRootPath: string,
candidatePath: string,
displayPath: string
): Promise<void> {
const rootRealPath = await realpath(sourceRootPath)
const candidateRealPath = await realpath(candidatePath)
const relativeToRoot = relative(rootRealPath, candidateRealPath)
// Why: `..name` is a valid child path; only `..` and `../...` escape.
if (
relativeToRoot !== '' &&
(relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot))
) {
throw new Error(`Path escaped upload root during upload: '${displayPath}'`)
}
}
@@ -0,0 +1,383 @@
import {
appendFile,
mkdir,
mkdtemp,
readFile,
rm,
stat,
truncate,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FileWriteBase64Chunk } from '../../shared/rpc-contract/files-mutation-params'
import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract'
// Why: real limits, real host write flags ('wx' then 'a') and the real chunk
// schema — the slice loop is exercised exactly at the boundaries it must respect.
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
type ChunkParams = { relativePath: string; contentBase64: string; append: boolean }
type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal }
type CallArgs = [
userDataPath: string,
environmentId: string,
method: string,
params: ChunkParams,
timeoutMs?: number,
expectedEnvironmentPairingRevision?: number,
envelope?: unknown,
options?: CallOptions
]
const callRuntimeEnvironment = vi.fn<(...args: CallArgs) => Promise<unknown>>()
// Why: vi.fn retains every call's params; a 2 GiB stream would pin ~2.8 GB of
// base64 in mock.calls and masquerade as a leak. Big tests swap in a plain fn.
let transportImpl: (...args: CallArgs) => Promise<unknown> = (...args) =>
callRuntimeEnvironment(...args)
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: (...args: CallArgs) => transportImpl(...args)
}))
const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } =
await import('./runtime-upload-file-stream')
const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging')
const { REMOTE_IMPORT_MAX_FILE_BYTES, REMOTE_IMPORT_MAX_TOTAL_BYTES, formatByteCeiling } =
await import('./runtime-import-limits')
const SLICE = RUNTIME_UPLOAD_SLICE_BYTES
const WIRE_CHUNK_CHARS = 512 * 1024
const OK = { id: 'x', ok: true, result: {}, _meta: {} }
let workDir: string
let remoteDir: string
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), 'orca-upload-bounds-'))
remoteDir = join(workDir, 'remote')
await mkdir(remoteDir)
callRuntimeEnvironment.mockReset()
callRuntimeEnvironment.mockResolvedValue(OK)
transportImpl = (...args) => callRuntimeEnvironment(...args)
})
afterEach(async () => {
await rm(workDir, { force: true, recursive: true })
})
function chunkCalls(): ChunkParams[] {
return callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.writeBase64Chunk')
.map(([, , , params]) => params)
}
/** Mirrors the host: first chunk is an exclusive create, appends open with 'a'. */
function installRealHostWrites(): void {
callRuntimeEnvironment.mockImplementation(async (_u, _e, method, params) => {
if (method === 'files.writeBase64Chunk') {
const parsed = FileWriteBase64Chunk.parse({ worktree: 'wt-1', ...params })
await writeFile(
join(remoteDir, parsed.relativePath),
Buffer.from(parsed.contentBase64, 'base64'),
{
flag: parsed.append ? 'a' : 'wx'
}
)
}
return OK
})
}
async function identityOf(path: string): Promise<StagedRuntimeUploadFileIdentity> {
const s = await stat(path)
return { byteLength: s.size, inode: s.ino, deviceId: s.dev, modifiedAtMs: s.mtimeMs }
}
async function argsFor(sourceRootPath: string, entryRelativePath = '', relativePath = 'dest.tmp') {
const target = entryRelativePath ? join(sourceRootPath, entryRelativePath) : sourceRootPath
return {
userDataPath: '/user-data',
environmentId: 'env-1',
sourceRootPath,
entryRelativePath,
expected: await identityOf(target),
worktree: 'wt-1',
relativePath,
expectedEnvironmentPairingRevision: 7,
expectedEnvironmentRuntimeId: 'rt-1'
}
}
function patterned(size: number, seed: number): Buffer {
const buffer = Buffer.allocUnsafe(size)
for (let i = 0; i < size; i += 1) {
buffer[i] = (i * 31 + seed) & 0xff
}
return buffer
}
describe('slice boundaries', () => {
const sizes = [
1,
2,
3,
4,
SLICE - 1,
SLICE,
SLICE + 1,
2 * SLICE - 1,
2 * SLICE,
2 * SLICE + 1,
3 * SLICE + 7
]
for (const size of sizes) {
it(`streams ${size} bytes as ceil(size/slice) schema-valid chunks that the host reassembles exactly`, async () => {
installRealHostWrites()
const contents = patterned(size, size)
const source = join(workDir, `s-${size}.bin`)
await writeFile(source, contents)
const dest = `dest-${size}.tmp`
await expect(streamExternalFileToRuntime(await argsFor(source, '', dest))).resolves.toEqual({
byteLength: size
})
const calls = chunkCalls()
const expectedChunks = Math.ceil(size / SLICE)
expect(calls).toHaveLength(expectedChunks)
expect(calls.map((c) => c.append)).toEqual(calls.map((_, i) => i > 0))
for (const [index, call] of calls.entries()) {
const isLast = index === calls.length - 1
expect(call.contentBase64.length).toBeLessThanOrEqual(WIRE_CHUNK_CHARS)
if (!isLast) {
expect(call.contentBase64.length).toBe(WIRE_CHUNK_CHARS)
}
expect(call.relativePath).toBe(dest)
}
const remote = await readFile(join(remoteDir, dest))
expect(remote.equals(contents)).toBe(true)
})
}
it('sends a zero-byte file as one empty exclusive create the host schema accepts', async () => {
installRealHostWrites()
const source = join(workDir, 'empty.bin')
await writeFile(source, '')
await expect(
streamExternalFileToRuntime(await argsFor(source, '', 'empty.tmp'))
).resolves.toEqual({
byteLength: 0
})
expect(chunkCalls()).toHaveLength(1)
expect(chunkCalls()[0]).toMatchObject({
relativePath: 'empty.tmp',
contentBase64: '',
append: false
})
expect((await stat(join(remoteDir, 'empty.tmp'))).size).toBe(0)
})
it('carries the pairing revision, runtime id and signal on every chunk', async () => {
const source = join(workDir, 'guards.bin')
await writeFile(source, patterned(2 * SLICE + 1, 3))
await streamExternalFileToRuntime(await argsFor(source))
const chunkInvocations = callRuntimeEnvironment.mock.calls.filter(
([, , method]) => method === 'files.writeBase64Chunk'
)
expect(chunkInvocations).toHaveLength(3)
for (const [, environmentId, , , timeoutMs, revision, envelope, options] of chunkInvocations) {
expect(environmentId).toBe('env-1')
expect(timeoutMs).toBe(30_000)
expect(revision).toBe(7)
expect(envelope).toBeUndefined()
expect(options?.expectedEnvironmentRuntimeId).toBe('rt-1')
}
})
})
describe('staging → streaming end to end on a real filesystem', () => {
it('streams every staged entry of a dropped directory using the identity staging recorded', async () => {
installRealHostWrites()
const root = join(workDir, 'drop me')
await mkdir(join(root, 'sub', 'deeper'), { recursive: true })
const files: Record<string, Buffer> = {
'a.txt': Buffer.from('alpha'),
'..keep': Buffer.from('dot-dot-prefixed name is a valid child'),
'héllo wörld.bin': patterned(SLICE, 9),
'sub/empty': Buffer.alloc(0),
'sub/deeper/big.bin': patterned(2 * SLICE + 5, 11)
}
for (const [rel, body] of Object.entries(files)) {
await writeFile(join(root, rel), body)
}
const staged = await stageOneSourceForRuntimeUpload(root)
expect(staged.status).toBe('staged')
if (staged.status !== 'staged') {
return
}
const fileEntries = staged.entries.filter((e) => e.kind === 'file')
expect(fileEntries.map((e) => e.relativePath).sort()).toEqual(Object.keys(files).sort())
for (const entry of fileEntries) {
if (entry.kind !== 'file') {
continue
}
const dest = `up-${entry.relativePath.replace(/[^a-z0-9]/gi, '_')}.tmp`
await expect(
streamExternalFileToRuntime({
userDataPath: '/u',
environmentId: 'env-1',
sourceRootPath: staged.sourcePath,
entryRelativePath: entry.relativePath,
expected: {
byteLength: entry.byteLength,
inode: entry.inode,
deviceId: entry.deviceId,
modifiedAtMs: entry.modifiedAtMs
},
worktree: 'wt-1',
relativePath: dest
})
).resolves.toEqual({ byteLength: files[entry.relativePath]!.length })
const remote = await readFile(join(remoteDir, dest))
expect(remote.equals(files[entry.relativePath]!)).toBe(true)
}
})
it('streams a dropped single file using the identity staging recorded', async () => {
installRealHostWrites()
const source = join(workDir, 'single.bin')
const body = patterned(SLICE + 1, 5)
await writeFile(source, body)
const staged = await stageOneSourceForRuntimeUpload(source)
expect(staged.status).toBe('staged')
if (staged.status !== 'staged') {
return
}
const entry = staged.entries[0]!
expect(entry.kind).toBe('file')
if (entry.kind !== 'file') {
return
}
await expect(
streamExternalFileToRuntime({
userDataPath: '/u',
environmentId: 'env-1',
sourceRootPath: staged.sourcePath,
entryRelativePath: entry.relativePath,
expected: entry,
worktree: 'wt-1',
relativePath: 'single.tmp'
})
).resolves.toEqual({ byteLength: body.length })
expect((await readFile(join(remoteDir, 'single.tmp'))).equals(body)).toBe(true)
})
})
describe('source mutation during transfer', () => {
it('rejects a source that grows during the transfer and never claims success', async () => {
const source = join(workDir, 'growing.bin')
await writeFile(source, patterned(2 * SLICE, 1))
const args = await argsFor(source)
callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => {
if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) {
await appendFile(source, 'extra')
}
return OK
})
await expect(streamExternalFileToRuntime(args)).rejects.toThrow(
"File changed during upload: 'growing.bin'"
)
})
it('rejects a source truncated during the transfer instead of sending a short file', async () => {
const source = join(workDir, 'shrinking.bin')
await writeFile(source, patterned(3 * SLICE, 2))
const args = await argsFor(source)
callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => {
if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) {
await truncate(source, SLICE)
}
return OK
})
await expect(streamExternalFileToRuntime(args)).rejects.toThrow(
"File truncated during upload: 'shrinking.bin'"
)
expect(chunkCalls().length).toBeLessThan(3)
})
it('accepts a staged identity whose inode and device are unreported (0) when size and mtime match', async () => {
const source = join(workDir, 'no-ino.bin')
await writeFile(source, patterned(10, 4))
const args = await argsFor(source)
args.expected = { ...args.expected, inode: 0, deviceId: 0 }
await expect(streamExternalFileToRuntime(args)).resolves.toEqual({ byteLength: 10 })
})
it('still refuses a wrong inode when only the device is unreported', async () => {
const source = join(workDir, 'wrong-ino.bin')
await writeFile(source, patterned(10, 4))
const args = await argsFor(source)
args.expected = { ...args.expected, inode: args.expected.inode + 1, deviceId: 0 }
await expect(streamExternalFileToRuntime(args)).rejects.toThrow(
"File changed since it was staged: 'wrong-ino.bin'"
)
expect(chunkCalls()).toHaveLength(0)
})
it('stops before the next slice when the signal aborts while a chunk is in flight', async () => {
const source = join(workDir, 'abort.bin')
await writeFile(source, patterned(3 * SLICE, 6))
const controller = new AbortController()
callRuntimeEnvironment.mockImplementation(async (_u, _e, method, _p, _t, _r, _env, options) => {
if (method !== 'files.writeBase64Chunk') {
return OK
}
if (chunkCalls().length === 2) {
return new Promise((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
once: true
})
controller.abort(new Error('window gone'))
})
}
return OK
})
await expect(
streamExternalFileToRuntime({ ...(await argsFor(source)), signal: controller.signal })
).rejects.toThrow('window gone')
expect(chunkCalls()).toHaveLength(2)
})
})
describe('formatByteCeiling bounds', () => {
it.each([
[0, '0 B'],
[1, '1 B'],
[1023, '1023 B'],
[1024, '1 KB'],
[1025, '1.1 KB'],
[25 * 1024 * 1024, '25 MB'],
[25 * 1024 * 1024 + 1, '25.1 MB'],
[REMOTE_IMPORT_MAX_FILE_BYTES, '2 GB'],
[REMOTE_IMPORT_MAX_FILE_BYTES + 1, '2.1 GB'],
[REMOTE_IMPORT_MAX_TOTAL_BYTES, '8 GB'],
[REMOTE_IMPORT_MAX_TOTAL_BYTES + 1, '8.1 GB'],
[1024 ** 5, '1024 TB']
])('%i → %s', (bytes, text) => {
expect(formatByteCeiling(bytes)).toBe(text)
})
})
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
const callRuntimeEnvironment =
vi.fn<
(
userDataPath: string,
environmentId: string,
method: string,
params: { relativePath: string; recursive: boolean },
timeoutMs?: number,
expectedEnvironmentPairingRevision?: number,
envelope?: unknown,
options?: { expectedEnvironmentRuntimeId?: string }
) => unknown
>()
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: (...args: Parameters<typeof callRuntimeEnvironment>) =>
callRuntimeEnvironment(...args)
}))
const { sweepAbandonedRuntimeUploadTempPath } = await import('./runtime-upload-temp-sweep')
const request: RuntimeUploadFileStreamRequest = {
environmentId: 'env-1',
sourceRootPath: '/Users/me/clip.mp4',
entryRelativePath: '',
expected: { byteLength: 4, inode: 1, deviceId: 2, modifiedAtMs: 3 },
worktree: 'id:wt-1',
relativePath: 'uploads/.clip.mp4.orca-upload-abc',
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'runtime-7',
expectedExecutionHostId: 'local'
}
function deleteCalls(): { relativePath: string; recursive: boolean }[] {
return callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.delete')
.map(([, , , params]) => params)
}
beforeEach(() => {
vi.useFakeTimers()
callRuntimeEnvironment.mockReset()
callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} })
})
afterEach(() => {
vi.useRealTimers()
})
describe('sweepAbandonedRuntimeUploadTempPath', () => {
it('deletes twice, because a straggling append recreates the file with flag a', async () => {
const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request)
await vi.runAllTimersAsync()
await swept
expect(deleteCalls()).toEqual([
expect.objectContaining({ relativePath: request.relativePath, recursive: false }),
expect.objectContaining({ relativePath: request.relativePath, recursive: false })
])
})
it('still makes the second pass when the first one fails', async () => {
callRuntimeEnvironment.mockRejectedValueOnce(new Error('connection lost'))
const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request)
await vi.runAllTimersAsync()
await expect(swept).resolves.toBeUndefined()
expect(deleteCalls()).toHaveLength(2)
})
it('carries the host ownership guards so it cannot delete on a re-paired host', async () => {
const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request)
await vi.runAllTimersAsync()
await swept
for (const call of callRuntimeEnvironment.mock.calls) {
expect(call[5]).toBe(17)
expect(call[7]?.expectedEnvironmentRuntimeId).toBe('runtime-7')
}
})
it('never rejects, so cleanup cannot mask the upload failure', async () => {
callRuntimeEnvironment.mockRejectedValue(new Error('runtime gone'))
const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request)
await vi.runAllTimersAsync()
await expect(swept).resolves.toBeUndefined()
})
})
+49
View File
@@ -0,0 +1,49 @@
import { setTimeout } from 'node:timers/promises'
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
const RUNTIME_UPLOAD_SWEEP_ATTEMPTS = 2
const RUNTIME_UPLOAD_SWEEP_SETTLE_MS = 250
/**
* Sweep an abandoned upload temp path after an abort.
*
* Aborting rejects the in-flight chunk locally, but the host may still apply
* that append — and appends open with `flag: 'a'`, which recreates the file a
* delete just removed. Slices are strictly sequential, so at most one append
* can be outstanding: a second pass after it has had time to land is enough.
*
* Best-effort throughout. The runtime may be why the upload failed, and a
* failed cleanup of a hidden temp file is not actionable.
*/
export async function sweepAbandonedRuntimeUploadTempPath(
userDataPath: string,
args: RuntimeUploadFileStreamRequest
): Promise<void> {
for (let attempt = 0; attempt < RUNTIME_UPLOAD_SWEEP_ATTEMPTS; attempt += 1) {
if (attempt > 0) {
await setTimeout(RUNTIME_UPLOAD_SWEEP_SETTLE_MS)
}
try {
await callRuntimeEnvironment(
userDataPath,
args.environmentId,
'files.delete',
{
worktree: args.worktree,
relativePath: args.relativePath,
recursive: false,
expectedSshTargetId: args.expectedSshTargetId,
expectedSshConnectionGeneration: args.expectedSshConnectionGeneration,
expectedExecutionHostId: args.expectedExecutionHostId
},
15_000,
args.expectedEnvironmentPairingRevision,
undefined,
{ expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId }
)
} catch {
// Nothing to escalate; the next pass (if any) still runs.
}
}
}
@@ -16,7 +16,6 @@ import type {
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection'
import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from './journal-dispatch-doubt-reasons'
import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection'
import { digestPayload } from './journal-payload-bounds'
import {
@@ -55,6 +54,8 @@ function userMessage(text: string): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
}
const LEGACY_CODEX_TURN_UNNAMED = 'codex app-server started a turn it did not name in time'
const journals = createTrackedJournalOpener()
async function open() {
@@ -196,6 +197,8 @@ describe('crash between provider accept and journal commit', () => {
expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false)
})
// Only an older Orca minted this reason -- Codex now settles a send on the
// provider echo -- but rows written under it still come back from disk.
it('keeps a codex turn it could not name in doubt, never rejected', async () => {
const journal = await open()
await journal.appendSubmission({
@@ -207,7 +210,7 @@ describe('crash between provider accept and journal commit', () => {
await journal.resolveDispatch({
clientMessageId: 'cm_codex_unnamed',
state: 'unknown',
reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED,
reason: LEGACY_CODEX_TURN_UNNAMED,
fence: 1
})
@@ -218,7 +221,7 @@ describe('crash between provider accept and journal commit', () => {
// and it may never become a rejection, which would license a re-delivery.
expect(restarted.submissions()[0]).toMatchObject({
dispatchState: 'unknown',
reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED,
reason: LEGACY_CODEX_TURN_UNNAMED,
recovered: true
})
})
@@ -23,13 +23,6 @@ export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_fa
/** The operation tombstone survived recovery but its journal submission did not. */
export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing'
/** Codex owns a turn it started but did not name, because its turn-start still
* settles on a deadline. The turn IS running, so this must never be treated as
* proof of non-delivery. Delete it once Codex settles on the app-server's
* turn-start response instead. */
export const DISPATCH_DOUBT_CODEX_TURN_UNNAMED =
'codex app-server started a turn it did not name in time'
/** The SDK took the frame, but its input pump did not prove whether the write completed. */
export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown'
@@ -8,7 +8,6 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../agent-session-journal/journal-dispatch-doubt-reasons'
import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns'
const journals = createTrackedJournalOpener()
@@ -39,7 +38,10 @@ describe('structured send idempotency', () => {
it.each([
['a refused write', 'provider_write_failed: broken pipe'],
['a dead host', 'host_restarted_before_acknowledgement'],
['a codex turn it could not name', DISPATCH_DOUBT_CODEX_TURN_UNNAMED]
[
'a codex turn an older Orca could not name',
'codex app-server started a turn it did not name in time'
]
])('never puts an unknown back on the wire after %s', async (_case, reason) => {
const body: AgentJournalMessageItem = {
kind: 'message',
@@ -6,7 +6,10 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons'
import {
DISPATCH_DOUBT_PROVIDER_EXITED,
DISPATCH_DOUBT_SUBMISSION_MISSING
} from '../agent-session-journal/journal-dispatch-doubt-reasons'
import {
accepted,
attach,
@@ -169,13 +172,15 @@ describe('send', () => {
expect(state.ok && state.page.submissions).toHaveLength(2)
})
it('refuses to redeliver a retry for a turn the provider already owns', async () => {
it('refuses to redeliver a retry for a message the provider may already hold', async () => {
await attach()
// A dead child ends the wait without proving non-delivery: the message was
// already written to that child's stdin.
dispatch.mockImplementationOnce(async () => ({
state: 'unknown' as const,
reason: 'codex app-server started a turn it did not name in time'
reason: DISPATCH_DOUBT_PROVIDER_EXITED
}))
const body = hostTestMessage('a turn codex owns but did not name')
const body = hostTestMessage('a message the provider may already hold')
const params = { envelope: envelope('agentSession.send', { body }), body }
const first = await host.send(CALLER, params)
@@ -183,8 +188,8 @@ describe('send', () => {
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
// The turn is running; a second delivery would be a duplicate, so Retry
// replays the recorded outcome instead of re-sending.
// No `unknown` is re-delivered under its own id, whatever its reason says,
// so Retry replays the recorded outcome instead of writing again.
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
@@ -68,3 +68,28 @@ describe.skipIf(process.platform === 'win32')('omp wrapper under a user alias na
expectAliasedOmpNameSurvives('/bin/zsh', 'setopt aliases')
})
})
describe.skipIf(process.platform === 'win32' || !zshAvailable)('OMP wrapper global aliases', () => {
it.each(['--help', '-v', 'models'])('parses with hostile global alias %s', (token) => {
const root = mkdtempSync(join(tmpdir(), 'orca-omp-global-alias-'))
roots.push(root)
const startup = join(root, 'startup.zsh')
writeFileSync(
startup,
[
`alias -g -- ${token}='${token} 2>&1 | cat'`,
getPosixOmpShellWrapper(),
`if ! __orca_omp_should_skip_extension '${token}'; then exit 1; fi`,
'printf "parsed\\n"',
`alias -g -- '${token}'`
].join('\n')
)
const result = spawnSync('/bin/zsh', ['-f', startup], {
encoding: 'utf8',
env: { ...process.env, HOME: root, ZDOTDIR: root }
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('parsed')
expect(result.stdout).toContain('2>&1 | cat')
})
})
+2 -2
View File
@@ -40,13 +40,13 @@ const OMP_SUBCOMMANDS = [
] as const
export function getPosixOmpShellWrapper(): string {
const subcommands = OMP_SUBCOMMANDS.join('|')
const subcommands = OMP_SUBCOMMANDS.map((value) => `'${value}'`).join('|')
return `# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as \`omp config\` keep
# their normal argv shape.
__orca_omp_should_skip_extension() {
case "\${1:-}" in
help|--help|-h|--version|-v) return 0 ;;
'help'|'--help'|'-h'|'--version'|'-v') return 0 ;;
${subcommands}) return 0 ;;
esac
return 1
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-14T11:25:01.730Z",
"platform": "darwin",
"command": ["bun", "tests/tools/omp-native-title-capture.mjs", "<read-only-omp-checkout>"],
"cols": 100,
"rows": 30,
"note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.",
"exitCode": 0
}
@@ -0,0 +1 @@
]0;π : Run a long task]0;π : release | π : note | OMP ! action required ✦]0;π > Run a long task]0;π > release | π : note | OMP ! action required ✦]0;π ! Run a long task]0;π ! release | π : note | OMP ! action required ✦
@@ -134,7 +134,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
return { handle, tabId: leaf.tabId, title }
}
}
return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title }
const tabId = pty.pty.tabId ?? pty.record.tabId
// A notifier can exist before its pane graph; retain the rename on the known tab.
if (this.notifier?.renameTerminal && tabId) {
this.persistHeadlessTerminalTitle(pty.pty.worktreeId, tabId, title)
this.notifier.renameTerminal(tabId, title)
}
return { handle, tabId, title }
}
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
@@ -0,0 +1,92 @@
import './orca-runtime-test-lifecycle.spec'
import type { RuntimeStore } from './runtime-store-contract'
import { describe, expect, it, vi } from 'vitest'
import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec'
import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
TEST_WORKTREE_ID,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} from './orca-runtime-test-fixtures.spec'
describe('terminal rename before renderer graph hydration', () => {
it.each(['Media Engine Orch', null])(
'persists and forwards title %s across PTY replacement',
async (title) => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
session.tabsByWorktree[TEST_WORKTREE_ID][0].customTitle = 'Previous name'
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures.
const checkedStore = runtimeStore as RuntimeStore
const runtime = new OrcaRuntimeService(checkedStore)
const renameTerminal = vi.fn()
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'omp-initial-pty' })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal,
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
await runtime.renameTerminal(created.handle, title)
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title)
expect(renameTerminal).toHaveBeenCalledWith('host-tab', title)
runtime.onPtyExit('omp-initial-pty', 0)
const restored = new OrcaRuntimeService(checkedStore)
restored.setPtyController({
spawn: vi.fn(async () => ({ id: 'omp-replacement-pty' })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await restored.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title)
}
)
it('does not recreate a closed persisted tab from a surviving PTY record', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Shared fixture implements RuntimeStore; its legacy Mock typing loses callable signatures.
const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore)
const notifier = createMobileCreateTestNotifier(vi.fn())
runtime.setNotifier(notifier)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'surviving-pty' })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
setSession({ ...getSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } })
runtimeStore.setWorkspaceSession.mockClear()
await runtime.renameTerminal(created.handle, 'Late rename')
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(runtimeStore.setWorkspaceSession).not.toHaveBeenCalled()
})
})
@@ -42,8 +42,8 @@ describe('structured mailbox pointer host', () => {
// The defect this pins: a running turn is announced by ONE lifecycle item, and settlement
// tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily
// far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is
// then delivered mid-turn, which Codex answers with `turn already running` and Claude settles
// `unknown` while the message is really queued.
// then delivered mid-turn, which Codex coalesces into the running turn and Claude queues behind
// it -- either way folded into work already in flight rather than read as a new instruction.
const items = [runningTurn(), ...transcript(500)]
hostRef.current = { journalSnapshot: () => ({ items }) }
expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({
@@ -81,11 +81,14 @@ export function structuredSessionGateFacts(
* Decide whether the nudge may be sent right now.
*
* Mid-turn delivery is refused for both providers rather than delegated to
* them: Codex answers a mid-turn `turn/start` with `turn already running`, and
* Claude accepts the frame but cannot acknowledge it inside the dispatch ack
* window, settling `unknown` while the message is really queued. Waiting for
* the turn to settle is the one contract that holds for both, and it preserves
* orchestration's existing idle-edge-only delivery policy.
* them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into
* the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none
* of which refuse it and none of which fire a second `turn/started` -- and
* Claude queues it behind the turn. Both therefore
* fold the nudge into work already in flight, where it reads as part of the
* running turn rather than a new instruction. Waiting for the turn to settle is
* the one contract that holds for both, and it preserves orchestration's
* existing idle-edge-only delivery policy.
*/
export function decideStructuredPointerDelivery(input: {
refusal: AgentSessionPtyWriteRefusal
@@ -49,8 +49,8 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen
* A structured worker's agent status, in the vocabulary `@idle` already matches on.
*
* Null when the session cannot be read: unknown must not read as idle, or a broadcast to `@idle`
* would wake a worker mid-turn — which Codex answers with `turn already running` and Claude queues
* behind the running turn.
* would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues
* behind it.
*/
export function structuredWorkerAgentStatus(sessionId: string): string | null {
const facts = readStructuredSessionGateFacts(sessionId)
@@ -17,7 +17,10 @@ import type {
} from '../codex/codex-app-server-connection'
import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths'
@@ -37,10 +40,16 @@ const SESSION = 'session-integration-1'
const THREAD = 'thread-integration'
const TURN = 'turn-1'
const WORKSPACE = 'workspace-1'
// The capability set the desktop renderer advertises. Without the pending-send
// one the host holds the reply until the send settles, which is a shim for
// clients too old to render a pending bubble — not what this suite models.
const CLIENT = {
clientId: 'device-a',
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
clientCapabilities: [
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
]
}
// ─── the fake `codex app-server` ────────────────────────────────────────────
@@ -16,8 +16,14 @@ import type {
openCodexAppServerConnection
} from '../codex/codex-app-server-connection'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../shared/agent-session-journal-types'
import type {
AgentSessionHistoryResult,
AgentSessionSubscribeEvent
@@ -43,10 +49,16 @@ const SESSION = 'session-integration-1'
const THREAD = 'thread-integration'
const TURN = 'turn-1'
const WORKSPACE = 'workspace-1'
// The capability set the desktop renderer advertises. Without the pending-send
// one the host holds the reply until the send settles, which is a shim for
// clients too old to render a pending bubble — not what this suite models.
const CLIENT = {
clientId: 'device-a',
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
clientCapabilities: [
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
]
}
// ─── the fake `codex app-server` ────────────────────────────────────────────
@@ -269,6 +281,13 @@ function textOf(item: AgentJournalRenderItem): string {
: ''
}
/** The durable submission row, which settlement rewrites after the send returns. */
function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined {
return getStructuredAgentSessionHost()
?.journalSnapshot(SESSION)
.submissions.find((entry) => entry.clientMessageId === clientMessageId)
}
async function historyPage(
direction: 'tail' | 'before' | 'after',
extra: Record<string, unknown> = {}
@@ -438,18 +457,25 @@ describe('a structured codex session over agentSession.*', () => {
envelope: envelope('agentSession.send', { body }, created.fence),
body
})
expect(sent.submission).toMatchObject({
dispatchState: 'accepted',
providerItemId: `codex:${THREAD}:${TURN}:0`
})
// Admission, not identity. `turn/start` proves Codex owns the message, but a
// send coalesced into a running turn is answered with that turn's id, so
// which message landed where is knowable only from the echo.
expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null })
expect(codex.live().calls.at(-1)).toMatchObject({
method: 'turn/start',
params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId }
})
codex.notify('turn/started', { turn: { id: TURN } })
// Codex echoes the message back carrying the `clientId` it was sent under,
// which is the only thing that names which submission this row settles.
codex.notify('item/completed', {
item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] }
item: {
type: 'userMessage',
id: 'item-0',
clientId: sent.clientMessageId,
content: [{ type: 'text', text: 'hi' }]
}
})
codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' })
@@ -459,6 +485,15 @@ describe('a structured codex session over agentSession.*', () => {
await drainStreamedEvents()
expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.'])
// The echo is the first item of this turn, so the settled key is ordinal 0 —
// minted by the same `identityFor` a history replay computes with, rather
// than guessed from the turn/start response.
await vi.waitFor(() =>
expect(submissionOf(sent.clientMessageId)).toMatchObject({
dispatchState: 'accepted',
providerItemId: `codex:${THREAD}:${TURN}:0`
})
)
})
it('runs create → send → stream → approval → cancel → reconnect → page history', async () => {
@@ -513,12 +548,10 @@ describe('a structured codex session over agentSession.*', () => {
envelope: envelope('agentSession.send', { body }, fence),
body
})
// Codex named the turn, so the submission is accepted rather than
// "delivery unconfirmed", and adopts the provider's own item identity.
expect(sent.submission).toMatchObject({
dispatchState: 'accepted',
providerItemId: `codex:${THREAD}:${TURN}:0`
})
// Codex took the message, so the submission is pending rather than
// "delivery unconfirmed" — it carries no identity yet, because the response
// to a coalesced send names the running turn rather than this message.
expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null })
expect(codex.live().calls.at(-1)).toMatchObject({
method: 'turn/start',
params: {
@@ -531,14 +564,28 @@ describe('a structured codex session over agentSession.*', () => {
// ── stream ──────────────────────────────────────────────────────────────
codex.notify('turn/started', { turn: { id: TURN } })
// Codex echoes the user message back as ordinal 0 of the turn. That is the
// key the submission adopted, so the echo has to reconcile into the bubble
// the client already has rather than append a second copy of it.
// Codex echoes the user message back as ordinal 0 of the turn, carrying the
// `clientId` it was sent under. That echo settles the submission's identity,
// and has to reconcile into the bubble the client already has rather than
// append a second copy of it.
codex.notify('item/completed', {
item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list files' }] }
item: {
type: 'userMessage',
id: 'item-0',
clientId: sent.clientMessageId,
content: [{ type: 'text', text: 'list files' }]
}
})
await drainStreamedEvents()
expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1)
// Settled from the echo's own journal identity, so it is by construction the
// key a replay recomputes for this row.
await vi.waitFor(() =>
expect(submissionOf(sent.clientMessageId)).toMatchObject({
dispatchState: 'accepted',
providerItemId: `codex:${THREAD}:${TURN}:0`
})
)
codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' })
@@ -121,9 +121,13 @@ describe('structured session runtime provider-exit wiring', () => {
})
}
// `pending` is this send's real answer now, not a weaker one: admission settles
// when the transport takes the frame, and identity arrives later on the
// provider's echo. What proves the message reached the REACQUIRED provider is
// the turn it starts below, which is what this test exists to check.
await expect(
host.send({ callerKey: 'runtime-test' }, { envelope, body })
).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } })
expect(turn).toBe(1)
})
@@ -7,6 +7,7 @@
// reads is module-level for the same reason the registry is — the runtime
// service is already far past its size budget.
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
@@ -246,6 +247,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
try {
let host: StructuredAgentSessionHost | null = null
let recoveryChain = Promise.resolve()
const onDispatchSettledLate = (settlement: {
sessionId: string
clientMessageId: string
providerIdentity: AgentJournalItemIdentity
}): void => {
void host?.settleLateDispatch(settlement).catch((error) =>
deps.onError?.({
scope: `structured-agent-session-late-settlement:${settlement.sessionId}`,
error
})
)
}
const codex = new CodexStructuredSessionAdapter({
resolveLaunch: createCodexStructuredLaunchResolver({
store,
@@ -257,6 +270,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
onBackgroundTasksChanged: (sessionId, state) =>
host?.publishBackgroundTaskState(sessionId, state),
onDispatchSettledLate,
onEvent: (event) => {
if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') {
return
@@ -298,14 +312,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
},
onBackgroundTasksChanged: (sessionId, state) =>
host?.publishBackgroundTaskState(sessionId, state),
onDispatchSettledLate: (settlement) => {
void host?.settleLateDispatch(settlement).catch((error) =>
deps.onError?.({
scope: `structured-agent-session-late-settlement:${settlement.sessionId}`,
error
})
)
},
onDispatchSettledLate,
...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {})
})
+4
View File
@@ -17,6 +17,7 @@ import type {
LocalLogTailWatchArgs
} from '../../shared/local-log-tail-types'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
export type ExportApi = {
htmlToPdf: (args: {
@@ -143,6 +144,9 @@ export type FilesystemApi = {
stageExternalPathsForRuntimeUpload: (args: {
sourcePaths: string[]
}) => Promise<{ sources: StagedExternalImportSource[] }>
uploadExternalFileToRuntime: (
args: RuntimeUploadFileStreamRequest
) => Promise<{ byteLength: number }>
resolveDroppedPathsForAgent: (
args: {
paths: string[]
+4
View File
@@ -1,6 +1,7 @@
import type { PathExistenceResult } from '../../shared/path-existence-batch'
import { ipcRenderer } from 'electron'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
import type { SearchResult } from '../../shared/code-search-types'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import type {
@@ -161,6 +162,9 @@ export const fsApi = {
sourcePaths: string[]
}): Promise<{ sources: StagedExternalImportSource[] }> =>
ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args),
uploadExternalFileToRuntime: (
args: RuntimeUploadFileStreamRequest
): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args),
resolveDroppedPathsForAgent: (
args: {
paths: string[]
@@ -15,4 +15,8 @@ describe('terminal container geometry', () => {
/\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s
)
})
it('bounds cursor-blink repaints to the terminal surface (#10481)', () => {
expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*contain:\s*paint;/s)
})
})
+4
View File
@@ -512,6 +512,10 @@
height: calc(100% - var(--pane-padding-y, 4px));
margin-top: var(--pane-padding-y, 4px);
margin-left: var(--pane-padding-x, 4px);
/* Why (#10481): a blinking cursor otherwise invalidates paint all the way up
the pane ancestry. The link tooltip and drag handle are .pane siblings, so
clipping to this box costs no visible chrome. */
contain: paint;
}
/* When a pane has a title, shift the terminal content down to make room.
@@ -276,7 +276,10 @@ export function NativeChatMentionHint({
event.preventDefault()
onAccept()
}}
className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
// Why z-20: matches the slash picker. The composer shell below is a paint
// containment boundary (#10481), so it now paints at z-index 0 in tree
// order and would otherwise cover this hint's drop shadow.
className="absolute bottom-full left-3 right-3 z-20 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
>
{translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '}
<span className="font-medium text-foreground">@{query || '…'}</span>
@@ -192,7 +192,15 @@ export function NativeChatComposerField({
// no focus/click border flash. The box is a container, not a
// focus target.
'rounded-lg border border-border p-1.5 shadow-xs',
'bg-muted/50 dark:bg-input/40'
'bg-muted/50 dark:bg-input/40',
// Why (#10481): the native caret blink invalidates paint up to the
// nearest containment boundary; without this the whole transcript
// re-rasterizes twice a second. Pickers are siblings and every menu
// and tooltip in here is a Radix portal, so nothing floating clips.
// Tightest descendant is the attachment remove button, which
// overhangs its thumbnail by 6px and clears this box's padding by
// 4px — keep that slack if the padding below ever shrinks.
'[contain:paint]'
)}
>
{imageAttachments.length > 0 ? (
@@ -0,0 +1,33 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
const composerField = fs.readFileSync(
new URL('./NativeChatComposerField.tsx', import.meta.url),
'utf8'
)
const autocompleteMenus = fs.readFileSync(
new URL('./NativeChatAutocompleteMenus.tsx', import.meta.url),
'utf8'
)
describe('native chat composer paint containment (#10481)', () => {
it('bounds caret repaints to the composer input shell', () => {
expect(composerField).toContain('[contain:paint]')
})
it('keeps the outer composer uncontained so the pickers can overflow it', () => {
// The pickers are siblings that render above the shell via `bottom-full`;
// containing their parent would clip them.
const outerShell = composerField.slice(0, composerField.indexOf('[contain:paint]'))
expect(outerShell).toContain('<div className="shrink-0 bg-background">')
expect(outerShell).not.toContain('contain:paint')
})
it('lifts both pickers above the contained shell', () => {
// The shell is a stacking context now, so it paints at z-index 0 in tree
// order — an unlayered picker would lose its drop shadow to it.
for (const picker of ['bottom-full left-0 right-0 z-20', 'bottom-full left-3 right-3 z-20']) {
expect(autocompleteMenus).toContain(picker)
}
})
})
@@ -90,6 +90,26 @@ describe('buildTitleDerivedAgentRows', () => {
])
})
it.each([
[':', 'working'],
['>', 'idle'],
['!', 'waiting']
])('retains hook-less OMP rows for owner marker %s', (marker, state) => {
const title = `OMP ${marker} Run a long task`
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'omp' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
ptyIdsByTabId: { 'tab-1': ['pty-omp'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
['omp', state, title]
])
})
it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'pi' })],
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Tab } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import {
applyDragPreviewTab,
captureTabDragActivationSnapshot,
restoreTabDragActivationSnapshot
restoreTabDragActivationSnapshot,
restoreSourceGroupActiveTabAfterCrossGroupDrop
} from './tab-drag-preview-activation'
const WT = 'wt-preview-restore'
@@ -59,6 +60,30 @@ describe('restoreTabDragActivationSnapshot', () => {
})
})
it('does not publish repeated preview and restore actions', () => {
const snapshot = captureTabDragActivationSnapshot(WT)
const subscriber = vi.fn()
const unsubscribe = useAppStore.subscribe(subscriber)
try {
applyDragPreviewTab({
worktreeId: WT,
groupId: 'group-1',
tabId: 'tab-1',
activeGroupId: 'group-1'
})
restoreTabDragActivationSnapshot(WT, snapshot)
restoreSourceGroupActiveTabAfterCrossGroupDrop({
worktreeId: WT,
snapshot,
sourceGroupId: 'group-1',
movedTabId: 'tab-2'
})
expect(subscriber).not.toHaveBeenCalled()
} finally {
unsubscribe()
}
})
it('restores active-surface fields after a drag preview is cancelled', () => {
const snapshot = captureTabDragActivationSnapshot(WT)
@@ -30,6 +30,14 @@ function previewActiveSurfacePatch(
})
if (unifiedTab.contentType === 'terminal') {
if (
state.activeTabType === 'terminal' &&
state.activeTabTypeByWorktree[worktreeId] === 'terminal' &&
state.activeTabId === unifiedTab.entityId &&
state.activeTabIdByWorktree[worktreeId] === unifiedTab.entityId
) {
return {}
}
return {
activeTabId: unifiedTab.entityId,
activeTabType: 'terminal',
@@ -41,6 +49,14 @@ function previewActiveSurfacePatch(
}
}
if (unifiedTab.contentType === 'browser') {
if (
state.activeTabType === 'browser' &&
state.activeTabTypeByWorktree[worktreeId] === 'browser' &&
state.activeBrowserTabId === unifiedTab.entityId &&
state.activeBrowserTabIdByWorktree[worktreeId] === unifiedTab.entityId
) {
return {}
}
return {
activeBrowserTabId: unifiedTab.entityId,
activeTabType: 'browser',
@@ -52,11 +68,25 @@ function previewActiveSurfacePatch(
}
}
if (unifiedTab.contentType === 'simulator') {
if (
state.activeTabType === 'simulator' &&
state.activeTabTypeByWorktree[worktreeId] === 'simulator'
) {
return {}
}
return {
activeTabType: 'simulator',
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator')
}
}
if (
state.activeTabType === 'editor' &&
state.activeTabTypeByWorktree[worktreeId] === 'editor' &&
state.activeFileId === unifiedTab.entityId &&
state.activeFileIdByWorktree[worktreeId] === unifiedTab.entityId
) {
return {}
}
return {
activeFileId: unifiedTab.entityId,
activeTabType: 'editor',
@@ -95,7 +125,7 @@ export function applyDragPreviewTab({
const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId
const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId)
if (groupUnchanged && focusUnchanged) {
return Object.keys(surfacePatch).length > 0 ? surfacePatch : {}
return Object.keys(surfacePatch).length > 0 ? surfacePatch : state
}
const next: Partial<AppState> = { ...surfacePatch }
@@ -162,7 +192,7 @@ export function restoreTabDragActivationSnapshot(
}
if (Object.keys(next).length === 0) {
return {}
return state
}
return next
@@ -191,7 +221,7 @@ export function restoreSourceGroupActiveTabAfterCrossGroupDrop({
const groups = state.groupsByWorktree[worktreeId] ?? []
const sourceGroup = groups.find((group) => group.id === sourceGroupId)
if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) {
return {}
return state
}
return {
groupsByWorktree: {
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY
// title tracker in main alongside the renderer transport's byte parser. Both
// must derive IDENTICAL ordered title/status facts from the same bytes, or
@@ -103,6 +104,19 @@ describe('main title tracker parity with the renderer transport processor', () =
vi.useRealTimers()
})
it('agrees on captured OMP native frames before and after owner rebranding', () => {
const captured = readFileSync(
new URL('../../../../main/runtime/__fixtures__/omp-native-title-win32.txt', import.meta.url),
'utf8'
)
feedBoth(paths, captured)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.some((event) => event.kind === 'became-working')).toBe(true)
expect(paths.main.events.some((event) => event.kind === 'became-idle')).toBe(true)
feedBoth(paths, captured.replaceAll(']0;π', ']0;OMP'))
expect(paths.main.events).toEqual(paths.renderer.events)
})
it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => {
// One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's
// trailing idle title. A last-title reader sees only the idle title and
+2
View File
@@ -21,6 +21,7 @@ export type AgentCatalogEntry = {
label: string
/** Default CLI binary name used for PATH detection. */
cmd: string
searchAliases?: readonly string[]
/** Direct or bundled image URL for agents whose project identity is not represented by a favicon service. */
iconUrl?: string
/** Domain for Google's favicon service — used for agents without an SVG icon. */
@@ -123,6 +124,7 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] =>
id: 'omp',
label: translate('auto.lib.agent.catalog.09973b4d84', 'OMP'),
cmd: 'omp',
searchAliases: ['oh-my-pi', 'oh my pi'],
// Why: no faviconDomain — omp renders the hand-authored OmpIcon glyph, so a
// favicon fallback would never be reached.
homepageUrl: 'https://omp.sh'
@@ -25,6 +25,15 @@ afterEach(() => {
})
describe('agent picker search', () => {
it.each(['oh-my-pi', 'oh my pi', 'OH-MY-PI'])('finds OMP by its project name: %s', (query) => {
expect(searchAgentPickerEntries(AGENT_CATALOG, query).map((agent) => agent.id)).toEqual(['omp'])
})
it('does not offer unavailable OMP through a search alias', () => {
const available = AGENT_CATALOG.filter((agent) => agent.id !== 'omp')
expect(searchAgentPickerEntries(available, 'oh-my-pi')).toEqual([])
})
it('keeps catalog order for an empty query', () => {
expect(searchAgentPickerEntries(agents, '').map((agent) => agent.id)).toEqual(
agents.map((agent) => agent.id)
+2 -1
View File
@@ -87,7 +87,8 @@ function scoreAgent(agent: AgentCatalogEntry, query: string): number {
return Math.min(
scoreCandidate(query, agent.label, 0),
scoreCandidate(query, agent.id, 600),
scoreCandidate(query, agent.cmd, 650)
scoreCandidate(query, agent.cmd, 650),
...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650))
)
}
@@ -12,10 +12,16 @@ import type { Terminal } from '@xterm/xterm'
* and the pane blinks — redrawing its whole cursor row through
* `WebglRenderer._updateModel` — until the 5-minute idle timeout.
*
* `cursorBlink` is the public option that tears the timer down deterministically
* `cursorBlink` is the public option that tears the timer down
* (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so
* "a hidden pane does not blink" stops depending on which CSS hid it.
*
* Not unconditional, though: `_updateCursorBlink` resolves
* `decPrivateModes.cursorBlink ?? options.cursorBlink`, and DECSCUSR with a
* blinking style (`CSI 5 SP q`) pins that DEC mode. On a pane whose shell or agent
* has emitted one, parking the option here has no effect and the hidden pane keeps
* blinking. Making this deterministic means clearing the DEC mode too.
*
* Resume restores the parked value rather than the settings value, so a pane that
* was not blinking before the hide never comes back blinking.
*/
@@ -4,6 +4,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi
import {
fsImportExternalPaths,
fsStageExternalPathsForRuntimeUpload,
fsUploadExternalFileToRuntime,
runtimeEnvironmentCall,
runtimeEnvironmentTransportCall,
installRuntimeFileClientEnvironment
@@ -11,11 +12,65 @@ import {
installRuntimeFileClientEnvironment()
const okResponse = (id: string): unknown => ({
id,
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
const notFoundResponse = (id: string): unknown => ({
id,
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
/** Matches what main-process staging now records for a file entry. */
const stagedFile = (
relativePath: string,
byteLength: number,
inode: number
): Record<string, unknown> => ({
relativePath,
kind: 'file',
byteLength,
inode,
deviceId: 66,
modifiedAtMs: 1_700_000_000_000
})
/** The upload request main receives; `never[]` mock args widen to it without a cast. */
type UploadRequest = {
environmentId: string
sourceRootPath: string
entryRelativePath: string
expected: Record<string, unknown>
worktree: string
relativePath: string
expectedExecutionHostId?: string
expectedSshTargetId?: string
expectedSshConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedEnvironmentRuntimeId?: string
}
function uploadRequests(): UploadRequest[] {
return fsUploadExternalFileToRuntime.mock.calls.flat()
}
const identityOf = (entry: Record<string, unknown>): Record<string, unknown> => ({
byteLength: entry.byteLength,
inode: entry.inode,
deviceId: entry.deviceId,
modifiedAtMs: entry.modifiedAtMs
})
describe('runtime file client', () => {
it('uploads a staged directory after one ownership and one cold compatibility preflight', async () => {
it('streams staged directory entries through main instead of sending base64 itself', async () => {
replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 17 }])
const firstChunk = 'A'.repeat(512 * 1024)
const secondChunk = 'BBBBBBBB'
const logo = stagedFile('logo.png', 3, 101)
const large = stagedFile('large.bin', 40 * 1024 * 1024, 102)
fsStageExternalPathsForRuntimeUpload.mockResolvedValue({
sources: [
{
@@ -23,85 +78,19 @@ describe('runtime file client', () => {
status: 'staged',
name: 'assets',
kind: 'directory',
entries: [
{ relativePath: '', kind: 'directory' },
{ relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' },
{
relativePath: 'large.bin',
kind: 'file',
contentBase64: `${firstChunk}${secondChunk}`
}
]
entries: [{ relativePath: '', kind: 'directory' }, logo, large]
}
]
})
runtimeEnvironmentCall
.mockResolvedValueOnce({
id: 'stat-destination-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'create-destination-dir',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'stat-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'create-dir',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-file',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'commit-upload',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'delete-temp',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-chunk-1',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-chunk-2',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'commit-large-upload',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'delete-large-temp',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce(notFoundResponse('stat-destination-miss'))
.mockResolvedValueOnce(okResponse('create-destination-dir'))
.mockResolvedValueOnce(notFoundResponse('stat-miss'))
.mockResolvedValueOnce(okResponse('create-dir'))
.mockResolvedValueOnce(okResponse('commit-upload'))
.mockResolvedValueOnce(okResponse('delete-temp'))
.mockResolvedValueOnce(okResponse('commit-large-upload'))
.mockResolvedValueOnce(okResponse('delete-large-temp'))
await expect(
importExternalPathsToRuntime(
@@ -136,75 +125,48 @@ describe('runtime file client', () => {
'files.createDir',
'files.stat',
'files.createDirNoClobber',
'files.writeBase64',
'files.commitUpload',
'files.delete',
'files.writeBase64Chunk',
'files.writeBase64Chunk',
'files.commitUpload',
'files.delete'
])
expect(transportCalls.filter((args) => args.method === 'status.get')).toHaveLength(2)
// Why: the whole point of the change — no file body crosses this boundary.
expect(transportCalls.some((args) => String(args.method).startsWith('files.writeBase64'))).toBe(
false
)
expect(transportCalls.every((args) => args.expectedEnvironmentPairingRevision === 17)).toBe(
true
)
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
selector: 'env-1',
method: 'files.stat',
params: {
worktree: 'id:wt-1',
relativePath: 'uploads'
},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: 17
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
selector: 'env-1',
method: 'files.createDir',
params: {
worktree: 'id:wt-1',
relativePath: 'uploads',
expectedExecutionHostId: 'local'
},
timeoutMs: 15_000,
const uploads = uploadRequests()
expect(uploads).toHaveLength(2)
expect(uploads[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/)
expect(uploads[0]).toEqual({
environmentId: 'env-1',
sourceRootPath: '/Users/me/assets',
entryRelativePath: 'logo.png',
expected: identityOf(logo),
worktree: 'id:wt-1',
relativePath: uploads[0]?.relativePath,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
selector: 'env-1',
method: 'files.stat',
params: {
worktree: 'id:wt-1',
relativePath: 'uploads/assets'
},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: 17
expect(uploads[1]).toMatchObject({
entryRelativePath: 'large.bin',
expected: identityOf(large)
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
selector: 'env-1',
method: 'files.createDirNoClobber',
params: {
worktree: 'id:wt-1',
relativePath: 'uploads/assets',
expectedExecutionHostId: 'local'
},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as {
params: { relativePath: string }
}
expect(smallWriteCall.params.relativePath).toMatch(
/^uploads\/assets\/\.logo\.png\.orca-upload-/
)
expect(uploads[1]?.relativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/)
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
selector: 'env-1',
method: 'files.writeBase64',
method: 'files.commitUpload',
params: {
worktree: 'id:wt-1',
relativePath: smallWriteCall.params.relativePath,
contentBase64: 'cG5n',
tempRelativePath: uploads[0]?.relativePath,
finalRelativePath: 'uploads/assets/logo.png',
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
@@ -214,99 +176,11 @@ describe('runtime file client', () => {
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
selector: 'env-1',
method: 'files.commitUpload',
params: {
worktree: 'id:wt-1',
tempRelativePath: smallWriteCall.params.relativePath,
finalRelativePath: 'uploads/assets/logo.png',
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, {
selector: 'env-1',
method: 'files.delete',
params: {
worktree: 'id:wt-1',
relativePath: smallWriteCall.params.relativePath,
recursive: false,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
const largeWriteParams = runtimeEnvironmentCall.mock.calls[7]?.[0].params
if (
typeof largeWriteParams !== 'object' ||
largeWriteParams === null ||
!('relativePath' in largeWriteParams) ||
typeof largeWriteParams.relativePath !== 'string'
) {
throw new Error('missing large file write call')
}
const largeWriteRelativePath = largeWriteParams.relativePath
expect(largeWriteRelativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/)
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(8, {
selector: 'env-1',
method: 'files.writeBase64Chunk',
params: {
worktree: 'id:wt-1',
relativePath: largeWriteRelativePath,
contentBase64: firstChunk,
append: false,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(9, {
selector: 'env-1',
method: 'files.writeBase64Chunk',
params: {
worktree: 'id:wt-1',
relativePath: largeWriteRelativePath,
contentBase64: secondChunk,
append: true,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(10, {
selector: 'env-1',
method: 'files.commitUpload',
params: {
worktree: 'id:wt-1',
tempRelativePath: largeWriteRelativePath,
finalRelativePath: 'uploads/assets/large.bin',
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: 17,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(11, {
selector: 'env-1',
method: 'files.delete',
params: {
worktree: 'id:wt-1',
relativePath: largeWriteRelativePath,
relativePath: uploads[0]?.relativePath,
recursive: false,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
@@ -319,9 +193,8 @@ describe('runtime file client', () => {
expect(fsImportExternalPaths).not.toHaveBeenCalled()
})
it('chunks large staged runtime uploads below the WebSocket frame budget', async () => {
const firstChunk = 'A'.repeat(512 * 1024)
const secondChunk = 'AA=='
it('forwards a single staged file with the identity staging measured', async () => {
const entry = stagedFile('', 40 * 1024 * 1024, 55)
fsStageExternalPathsForRuntimeUpload.mockResolvedValue({
sources: [
{
@@ -329,55 +202,16 @@ describe('runtime file client', () => {
status: 'staged',
name: 'large.bin',
kind: 'file',
entries: [
{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` }
]
entries: [entry]
}
]
})
runtimeEnvironmentCall
.mockResolvedValueOnce({
id: 'stat-destination-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'create-destination-dir',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'stat-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-chunk-1',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-chunk-2',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'commit-upload',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'delete-temp',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce(notFoundResponse('stat-destination-miss'))
.mockResolvedValueOnce(okResponse('create-destination-dir'))
.mockResolvedValueOnce(notFoundResponse('stat-miss'))
.mockResolvedValueOnce(okResponse('commit-upload'))
.mockResolvedValueOnce(okResponse('delete-temp'))
await expect(
importExternalPathsToRuntime(
@@ -401,79 +235,20 @@ describe('runtime file client', () => {
]
})
const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as {
params: { relativePath: string }
}
expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/)
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
selector: 'env-1',
method: 'files.writeBase64Chunk',
params: {
worktree: 'id:wt-1',
relativePath: chunkWriteCall.params.relativePath,
contentBase64: firstChunk,
append: false,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: undefined,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
selector: 'env-1',
method: 'files.writeBase64Chunk',
params: {
worktree: 'id:wt-1',
relativePath: chunkWriteCall.params.relativePath,
contentBase64: secondChunk,
append: true,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: undefined,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
selector: 'env-1',
method: 'files.commitUpload',
params: {
worktree: 'id:wt-1',
tempRelativePath: chunkWriteCall.params.relativePath,
finalRelativePath: 'uploads/large.bin',
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 30_000,
expectedEnvironmentPairingRevision: undefined,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, {
selector: 'env-1',
method: 'files.delete',
params: {
worktree: 'id:wt-1',
relativePath: chunkWriteCall.params.relativePath,
recursive: false,
expectedExecutionHostId: 'local',
expectedSshTargetId: undefined,
expectedSshConnectionGeneration: undefined
},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: undefined,
expectedEnvironmentRuntimeId: 'remote-runtime'
})
const upload = uploadRequests()[0]
expect(upload?.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/)
expect(upload?.sourceRootPath).toBe('/Users/me/large.bin')
expect(upload?.entryRelativePath).toBe('')
expect(upload?.expected).toEqual(identityOf(entry))
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.writeBase64' })
)
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.writeBase64Chunk' })
)
})
it('stops a chunked upload when its owner generation changes between writes', async () => {
const firstChunk = 'A'.repeat(512 * 1024)
it('does not commit an upload when the owner generation changes while it streams', async () => {
fsStageExternalPathsForRuntimeUpload.mockResolvedValue({
sources: [
{
@@ -481,7 +256,7 @@ describe('runtime file client', () => {
status: 'staged',
name: 'large.bin',
kind: 'file',
entries: [{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}BBBBBBBB` }]
entries: [stagedFile('', 40 * 1024 * 1024, 55)]
}
]
})
@@ -492,22 +267,12 @@ describe('runtime file client', () => {
result: { size: 0, isDirectory: true, mtime: 1 },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'stat-file-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockImplementationOnce(async () => {
ownerChanged = true
return {
id: 'write-chunk-1',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
}
})
.mockResolvedValueOnce(notFoundResponse('stat-file-miss'))
let ownerChanged = false
fsUploadExternalFileToRuntime.mockImplementation(async () => {
ownerChanged = true
return { byteLength: 40 * 1024 * 1024 }
})
const assertCurrent = vi.fn(() => {
if (ownerChanged) {
throw new Error('runtime owner generation changed')
@@ -531,20 +296,14 @@ describe('runtime file client', () => {
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'files.stat',
'files.stat',
'files.writeBase64Chunk'
'files.stat'
])
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.commitUpload' })
)
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.delete' })
)
})
it('cleans up staged runtime upload temp files when a later chunk fails', async () => {
const firstChunk = 'A'.repeat(512 * 1024)
const secondChunk = 'BBBBBBBB'
it('cleans up the staged temp path when the streamed upload fails', async () => {
fsStageExternalPathsForRuntimeUpload.mockResolvedValue({
sources: [
{
@@ -552,49 +311,19 @@ describe('runtime file client', () => {
status: 'staged',
name: 'large.bin',
kind: 'file',
entries: [
{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` }
]
entries: [stagedFile('', 40 * 1024 * 1024, 55)]
}
]
})
runtimeEnvironmentCall
.mockResolvedValueOnce({
id: 'stat-destination-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'create-destination-dir',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'stat-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-chunk-1',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-chunk-2',
ok: false,
error: { code: 'write_failed', message: 'disk full' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'delete-temp',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce(notFoundResponse('stat-destination-miss'))
.mockResolvedValueOnce(okResponse('create-destination-dir'))
.mockResolvedValueOnce(notFoundResponse('stat-miss'))
.mockResolvedValueOnce(okResponse('delete-temp'))
// Electron wraps a main-process throw; the reason must not leak that.
fsUploadExternalFileToRuntime.mockRejectedValue(
new Error("Error invoking remote method 'fs:uploadExternalFileToRuntime': Error: disk full")
)
await expect(
importExternalPathsToRuntime(
@@ -610,13 +339,7 @@ describe('runtime file client', () => {
results: [{ status: 'failed', reason: 'disk full' }]
})
const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as
| { params: { relativePath: string } }
| undefined
if (!chunkCall) {
throw new Error('missing first chunk call')
}
const tempRelativePath = chunkCall.params.relativePath
const tempRelativePath = uploadRequests()[0]?.relativePath
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.commitUpload' })
)
@@ -645,10 +368,7 @@ describe('runtime file client', () => {
status: 'staged',
name: 'assets',
kind: 'directory',
entries: [
{ relativePath: '', kind: 'directory' },
{ relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' }
]
entries: [{ relativePath: '', kind: 'directory' }, stagedFile('logo.png', 3, 101)]
}
]
})
@@ -659,36 +379,11 @@ describe('runtime file client', () => {
result: { size: 0, isDirectory: true, mtime: 1 },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'stat-import-root-miss',
ok: false,
error: { code: 'not_found', message: 'not found' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'create-import-root',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'write-file',
ok: false,
error: { code: 'write_failed', message: 'disk full' },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'delete-temp',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'delete-import-root',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce(notFoundResponse('stat-import-root-miss'))
.mockResolvedValueOnce(okResponse('create-import-root'))
.mockResolvedValueOnce(okResponse('delete-temp'))
.mockResolvedValueOnce(okResponse('delete-import-root'))
fsUploadExternalFileToRuntime.mockRejectedValue(new Error('disk full'))
await expect(
importExternalPathsToRuntime(
@@ -704,13 +399,7 @@ describe('runtime file client', () => {
results: [{ status: 'failed', reason: 'disk full' }]
})
const writeCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as
| { params: { relativePath: string } }
| undefined
if (!writeCall) {
throw new Error('missing failed file write call')
}
expect(writeCall.params.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/)
expect(uploadRequests()[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/)
expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({
selector: 'env-1',
method: 'files.delete',
@@ -763,6 +452,7 @@ describe('runtime file client', () => {
expectedSshConnectionGeneration: 5
})
expect(fsStageExternalPathsForRuntimeUpload).not.toHaveBeenCalled()
expect(fsUploadExternalFileToRuntime).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
})
@@ -54,6 +54,7 @@ export const fsFinishDownloadedFile: PreloadStub = vi.fn()
export const fsCancelDownloadedFile: PreloadStub = vi.fn()
export const fsImportExternalPaths: PreloadStub = vi.fn()
export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn()
export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn()
export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn()
export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn()
export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn()
@@ -88,6 +89,8 @@ export function installRuntimeFileClientEnvironment(): void {
fsCancelDownloadedFile.mockReset()
fsImportExternalPaths.mockReset()
fsStageExternalPathsForRuntimeUpload.mockReset()
fsUploadExternalFileToRuntime.mockReset()
fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 })
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentSubscribe.mockReset()
@@ -131,7 +134,8 @@ export function installRuntimeFileClientEnvironment(): void {
finishDownloadedFile: fsFinishDownloadedFile,
cancelDownloadedFile: fsCancelDownloadedFile,
importExternalPaths: fsImportExternalPaths,
stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload
stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload,
uploadExternalFileToRuntime: fsUploadExternalFileToRuntime
},
runtime: { call: runtimeCall },
runtimeEnvironments: {
@@ -113,7 +113,16 @@ export async function importExternalPathsToRuntime(
importSession,
context.worktreeId,
entryRelativePath,
entry.contentBase64,
{
sourceRootPath: source.sourcePath,
entryRelativePath: entry.relativePath,
expected: {
byteLength: entry.byteLength,
inode: entry.inode,
deviceId: entry.deviceId,
modifiedAtMs: entry.modifiedAtMs
}
},
context.expectedSshConnectionGeneration,
context.expectedSshTargetId,
context.expectedExecutionHostId ??

Some files were not shown because too many files have changed in this diff Show More