diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 53501add289..4f667bf2778 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -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" diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index a758d8db3a1..41b87526fea 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -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" diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml new file mode 100644 index 00000000000..1602bae956a --- /dev/null +++ b/.github/workflows/git-command-termination-runtime.yml @@ -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 diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index b4bdd803a6a..1aed485a666 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -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" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b21feae3230..490eda88c33 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -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 \ diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index f678fecc4bb..df300383c1b 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.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>, @@ -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) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 41ead67ea60..dee6a16e523 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -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 diff --git a/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts new file mode 100644 index 00000000000..251e3670759 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts @@ -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 { + 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 { + const client = new pg.Client({ connectionString: url, statement_timeout: 2_000 }) + await client.connect() + return client + } + + async function installed(client: pg.Client): Promise { + 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() + } + }) +}) diff --git a/cloud/apps/relay/src/postgres-statement-stats.ts b/cloud/apps/relay/src/postgres-statement-stats.ts new file mode 100644 index 00000000000..61a2fee3b75 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats.ts @@ -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$; +` diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index 4515048b29b..ea5717daa87 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -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 diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs index 62a28c6a374..074af9ec3d9 100644 --- a/config/scripts/dev-channel-base-version.mjs +++ b/config/scripts/dev-channel-base-version.mjs @@ -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) diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs index d2631eff0e9..00c13f6817a 100644 --- a/config/scripts/dev-channel-base-version.test.mjs +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -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') }) diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 08fc9d28b81..7438b16acbd 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -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) + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ed4e1b1f1c8..d18837a1573 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -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', diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 497a58653a0..8ebb2fced9d 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -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' diff --git a/docs/reference/omp-history-titles.md b/docs/reference/omp-history-titles.md new file mode 100644 index 00000000000..f238574ef2a --- /dev/null +++ b/docs/reference/omp-history-titles.md @@ -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. diff --git a/mobile/README.md b/mobile/README.md index 64f1081b73c..e78c2c410bf 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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 diff --git a/mobile/scripts/mock-server-native-chat-scenario.ts b/mobile/scripts/mock-server-native-chat-scenario.ts index d2f65e0b635..b9d18348f7b 100644 --- a/mobile/scripts/mock-server-native-chat-scenario.ts +++ b/mobile/scripts/mock-server-native-chat-scenario.ts @@ -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>>() @@ -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': { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index b79ed543494..536831230b4 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -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 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 3d3403ad099..f86bd569381 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -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 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index dc14486cdb7..a11d3e6183e 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -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 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 10e9e144fc0..35a262b5e02 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -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 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index de9c8f95248..61bdd01dd50 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -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 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 394bc4a6d10..ff90115d30d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -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 } diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index 71215bb7174..435fa16ce8a 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -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 & { - 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 & { + 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 + })) + ) } /** diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3ca4e754106..353decfcd46 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -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 diff --git a/src/main/ai-vault/session-scanner-omp-title.test.ts b/src/main/ai-vault/session-scanner-omp-title.test.ts new file mode 100644 index 00000000000..3e2d6a966cf --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.test.ts @@ -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') + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-title.ts b/src/main/ai-vault/session-scanner-omp-title.ts new file mode 100644 index 00000000000..e734350caad --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.ts @@ -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 +): 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 } +} diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index 29039ffca3c..fbf50badaef 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -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() diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts new file mode 100644 index 00000000000..3eece95c3fe --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -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>, + clientMessageId: string +): Promise { + 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([]) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts new file mode 100644 index 00000000000..58203b26964 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -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() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts new file mode 100644 index 00000000000..8ea97561c59 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -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() + 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, + 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 +} diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts new file mode 100644 index 00000000000..5519ffdfdb8 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -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 | undefined) => unknown + +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] +} + +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 = {}): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + 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 + settlements: LateSettlement[] + sink?: StructuredAgentSessionEventSink +}): Promise { + 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 } + }) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 917133c7543..3029bff280b 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -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' }) diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index d7a902c9484..4114f9b0355 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -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 diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 62091580da3..b5ab9ad90e1 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -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') { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index d1cd7ca2884..e36322bca0c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -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 diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 989232ff1b2..69b4cb0d392 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -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( diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b104c559af2..c191e675973 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -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) => diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b32c7e69a1b..d32c3013b6c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -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': () => { diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 2ea81d44786..1ac807a5ccd 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -257,9 +257,8 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { turnId: 'turn-2' } + ).resolves.toEqual({ + state: 'admitted' }) }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 58c5bc5f50a..17f3aecf671 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -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 - // 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({ diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index af814c51d9b..2058f86ce85 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -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?.() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 4e9abfc17b5..0a8bf41688c 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -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 } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 625e222ecfb..df66d436bf1 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -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 mintLinkId?: () => string @@ -94,7 +104,8 @@ export type CodexSession = { } /** Exact provider-advertised Fast request value for each discovered model. */ fastModeTierByModel: Map - 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 diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index e6a53925fc6..c88370adf05 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -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 threadId: string options: Map reportedOptions?: { model?: string } fastModeTierByModel: ReadonlyMap - turnIdWaiters: ((turnId: string) => void)[] + dispatchEchoes: CodexDispatchEchoes } function turnInputFor(body: AgentJournalMessageItem): Record[] { @@ -92,69 +85,54 @@ function codexTurnOptions(host: CodexTurnHost): Record { } /** - * 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 { - // 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((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 { + // 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 { - 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' } } diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts index 89ed72666db..7e21b900d96 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -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 diff --git a/src/main/git/command-runner/spawned-command-tree-kill.test.ts b/src/main/git/command-runner/spawned-command-tree-kill.test.ts new file mode 100644 index 00000000000..758fb296227 --- /dev/null +++ b/src/main/git/command-runner/spawned-command-tree-kill.test.ts @@ -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()), + 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('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 + } + ) +}) diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts index 324e04f1db3..035764d5249 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -9,6 +9,11 @@ export function killSpawnedCommandTree(child: ChildProcess): Promise { 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' }) ) { diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index bb143987c96..7703cd8a8b1 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -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() }) diff --git a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts new file mode 100644 index 00000000000..fa7edc1d5b7 --- /dev/null +++ b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map Promise>() +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 { + 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) + }) +}) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 15aa5a7e879..ad1308f0c30 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -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 diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts new file mode 100644 index 00000000000..3fe70b4e1f2 --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -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()), + 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 }) + ]) + ) + }) +}) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index eba4b52357a..6531f8a499a 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -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 { 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> @@ -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 { +async function stageDirectoryEntries( + rootPath: string, + totalBytesBefore: number +): Promise { const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] - let totalBytes = 0 + let totalBytes = totalBytesBefore const rootRealPath = await realpath(rootPath) async function visit(dirPath: string): Promise { @@ -126,52 +147,52 @@ async function stageDirectoryEntries(rootPath: string): Promise { 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` + ) } } diff --git a/src/main/ipc/renderer-lifetime-abort.test.ts b/src/main/ipc/renderer-lifetime-abort.test.ts new file mode 100644 index 00000000000..9e5b0f9a7d9 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.test.ts @@ -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) + }) +}) diff --git a/src/main/ipc/renderer-lifetime-abort.ts b/src/main/ipc/renderer-lifetime-abort.ts new file mode 100644 index 00000000000..213abab8f47 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.ts @@ -0,0 +1,45 @@ +import type { WebContents } from 'electron' + +export type RendererLifetimeSender = Pick + +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) + } + } +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index d461b1d080a..84d2754d556 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -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 } } diff --git a/src/main/ipc/runtime-environment-manual-disconnect.ts b/src/main/ipc/runtime-environment-manual-disconnect.ts index f9f94e7f438..31c300895df 100644 --- a/src/main/ipc/runtime-environment-manual-disconnect.ts +++ b/src/main/ipc/runtime-environment-manual-disconnect.ts @@ -1,5 +1,7 @@ const manuallyDisconnectedEnvironmentIds = new Set() +export const RUNTIME_MANUALLY_DISCONNECTED_MESSAGE = 'Runtime environment is manually disconnected.' + export function markRuntimeEnvironmentManuallyDisconnected(environmentId: string): void { manuallyDisconnectedEnvironmentIds.add(environmentId) } diff --git a/src/main/ipc/runtime-import-limits.test.ts b/src/main/ipc/runtime-import-limits.test.ts new file mode 100644 index 00000000000..2eaeae603b0 --- /dev/null +++ b/src/main/ipc/runtime-import-limits.test.ts @@ -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') + }) +}) diff --git a/src/main/ipc/runtime-import-limits.ts b/src/main/ipc/runtime-import-limits.ts new file mode 100644 index 00000000000..80fd680a24e --- /dev/null +++ b/src/main/ipc/runtime-import-limits.ts @@ -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]}` +} diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts new file mode 100644 index 00000000000..4d11bc18652 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -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) => + 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()), + 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 { + 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) + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts new file mode 100644 index 00000000000..28271774ce0 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -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- 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 { + // 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 { + 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}'`) + } +} diff --git a/src/main/ipc/runtime-upload-slice-boundaries.test.ts b/src/main/ipc/runtime-upload-slice-boundaries.test.ts new file mode 100644 index 00000000000..0c2bd457b1f --- /dev/null +++ b/src/main/ipc/runtime-upload-slice-boundaries.test.ts @@ -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>() +// 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 = (...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 { + 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 = { + '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) + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.test.ts b/src/main/ipc/runtime-upload-temp-sweep.test.ts new file mode 100644 index 00000000000..06e7fe9282c --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.test.ts @@ -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) => + 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() + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.ts b/src/main/ipc/runtime-upload-temp-sweep.ts new file mode 100644 index 00000000000..46150fd96e5 --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.ts @@ -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 { + 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. + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index c8d48ef336b..d142672f8e5 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -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 }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index dbd56132100..206bd19e3ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -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' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index c0b56aa7d14..477ed785918 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -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', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index e2f294f40f7..f2d1ab12230 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -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' } } diff --git a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts index 3863b3aacaf..d1d0e0aad83 100644 --- a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts +++ b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts @@ -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') + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index f5bc25421bb..de1d9bed2af 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -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 diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json new file mode 100644 index 00000000000..6f64734a888 --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-14T11:25:01.730Z", + "platform": "darwin", + "command": ["bun", "tests/tools/omp-native-title-capture.mjs", ""], + "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 +} diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.txt b/src/main/runtime/__fixtures__/omp-native-title-win32.txt new file mode 100644 index 00000000000..ac67e688dce --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.txt @@ -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 ✦ \ No newline at end of file diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 0d934548332..44f4524c782 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -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) diff --git a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts new file mode 100644 index 00000000000..87aabaf4c54 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fe08e5f808d..91bf1158e07 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -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({ diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 272d7799947..dd8ccf64f71 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -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 diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 8abad118de7..168bd870118 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -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) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 990aa293ee4..4fa390be0fb 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -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` ──────────────────────────────────────────── diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index f5a59033d73..219b132566e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -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 = {} @@ -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 ' }) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 506a45ae821..7a3736b9009 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -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) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index fbc13b58fca..e437739a58b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -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 { + 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 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 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 } : {}) }) diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 9faf5b31d0b..acb49f500d3 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -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[] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index e034e6d08fa..2f702d9464f 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -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[] diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..cf17cb00540 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 1ee09586892..0d17b1f77df 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -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. diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 125d4326b8f..2e1d05e0ffe 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -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:')}{' '} @{query || '…'} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..a4b104efd0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -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 ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts new file mode 100644 index 00000000000..2c8a08ffa89 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts @@ -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('
') + 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) + } + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index c5de2eb1813..b0e7340f373 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -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' })], diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts index 019b3e297ee..1269ade41a4 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -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) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts index a0060271c99..31726672db2 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts @@ -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 = { ...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: { diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 09126e91c6b..dac6bbaf326 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -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 diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index b6075183947..cf46fd9aae7 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -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' diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts index fe26fd53dc3..12fe441103f 100644 --- a/src/renderer/src/lib/agent-picker-search.test.ts +++ b/src/renderer/src/lib/agent-picker-search.test.ts @@ -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) diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts index 6fb980bc8ea..38c368f3d73 100644 --- a/src/renderer/src/lib/agent-picker-search.ts +++ b/src/renderer/src/lib/agent-picker-search.ts @@ -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)) ) } diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts index 13f90af696c..6cdb5dd7508 100644 --- a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -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. */ diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index fb0f0e8e092..52f0bb83d09 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -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 => ({ + 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 + 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): Record => ({ + 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() }) }) diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 844080626be..321c6abe6d1 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -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: { diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 44a1f56df79..2aebfbcfa8c 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -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 ?? diff --git a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts index 34da21c8966..eb8dad53970 100644 --- a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts @@ -32,6 +32,7 @@ type RuntimeCallArgs = { const runtimeEnvironmentCall = vi.fn<(args: RuntimeCallArgs) => unknown>() const stageExternalPathsForRuntimeUpload = vi.fn() +const uploadExternalFileToRuntime = vi.fn<(args: Record) => unknown>() const importExternalPaths = vi.fn() const nestedSshContext = { @@ -99,7 +100,8 @@ function repairedRuntimeResponse(method: string) { } } -function mockStagedFile(sourcePath: string, name: string, contentBase64: string): void { +/** Staging now hands over identity, not a body; the streamer in main reads the bytes. */ +function mockStagedFile(sourcePath: string, name: string, byteLength: number): void { stageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -107,12 +109,28 @@ function mockStagedFile(sourcePath: string, name: string, contentBase64: string) status: 'staged', name, kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64 }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength, + inode: 91, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } + ] } ] }) } +function expectUploadsBoundToCapturedRevision(): void { + for (const [args] of uploadExternalFileToRuntime.mock.calls) { + expect(args.expectedEnvironmentPairingRevision).toBe(CAPTURED_REVISION) + expect(args.expectedEnvironmentRuntimeId).toBe('hub-runtime') + } +} + function expectEveryRuntimeCallBoundToCapturedRevision(ownership: { expectedExecutionHostId: string expectedSshTargetId?: string @@ -146,12 +164,15 @@ beforeEach(() => { markRuntimeEnvironmentCompatible(ENVIRONMENT_ID) runtimeEnvironmentCall.mockReset() stageExternalPathsForRuntimeUpload.mockReset() + uploadExternalFileToRuntime.mockReset() + uploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) importExternalPaths.mockReset() vi.stubGlobal('window', { api: { fs: { importExternalPaths, - stageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime }, runtimeEnvironments: { call: runtimeEnvironmentCall @@ -183,7 +204,7 @@ describe('runtime file import pairing revision', () => { }) it('stops when the HUB runtime changes without a pairing change', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -191,14 +212,15 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setRuntimeEnvironmentConnectionGenerationForTests( - ENVIRONMENT_ID, - REPLACEMENT_CONNECTION_GENERATION - ) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setRuntimeEnvironmentConnectionGenerationForTests( + ENVIRONMENT_ID, + REPLACEMENT_CONNECTION_GENERATION + ) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -208,9 +230,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) + expectUploadsBoundToCapturedRevision() expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -236,8 +258,8 @@ describe('runtime file import pairing revision', () => { expect(importExternalPaths).not.toHaveBeenCalled() }) - it('stops a rich-markdown upload between chunks without contacting the replacement HUB', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + it('never commits a streamed upload against a replacement HUB re-paired mid-stream', async () => { + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -245,11 +267,12 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setEnvironmentRevision(REPLACEMENT_REVISION) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setEnvironmentRevision(REPLACEMENT_REVISION) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -259,20 +282,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - method: 'files.writeBase64Chunk', - expectedEnvironmentPairingRevision: CAPTURED_REVISION, - params: expect.objectContaining({ - contentBase64: 'A'.repeat(512 * 1024), - append: false - }) - }) - ) + expectUploadsBoundToCapturedRevision() expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) @@ -283,7 +295,7 @@ describe('runtime file import pairing revision', () => { }) it('keeps a HUB-local composer commit on its entry revision when re-paired during commit', async () => { - mockStagedFile('/client/note.txt', 'note.txt', 'bm90ZQ==') + mockStagedFile('/client/note.txt', 'note.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.expectedEnvironmentPairingRevision !== CAPTURED_REVISION) { throw new Error('replacement HUB received an import RPC') @@ -310,14 +322,13 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(hubLocalContext) }) it('does not clean up against a replacement HUB after commit', async () => { - mockStagedFile('/client/drop.txt', 'drop.txt', 'ZHJvcA==') + mockStagedFile('/client/drop.txt', 'drop.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -340,7 +351,6 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) @@ -356,7 +366,14 @@ describe('runtime file import pairing revision', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'broken.txt', kind: 'file', contentBase64: 'YnJva2Vu' } + { + relativePath: 'broken.txt', + kind: 'file', + byteLength: 6, + inode: 92, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } ] } ] @@ -368,16 +385,9 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64') { - return { - id: args.method, - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'hub-runtime' } - } - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/assets'], '/ssh/repo') @@ -387,7 +397,6 @@ describe('runtime file import pairing revision', () => { 'status.get', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.delete', 'files.delete' ]) diff --git a/src/renderer/src/runtime/runtime-file-upload-client.ts b/src/renderer/src/runtime/runtime-file-upload-client.ts index 2f3c1582ec5..fc3d0f83a9a 100644 --- a/src/renderer/src/runtime/runtime-file-upload-client.ts +++ b/src/renderer/src/runtime/runtime-file-upload-client.ts @@ -1,4 +1,6 @@ +import { extractIpcErrorMessage } from '@/lib/ipc-error' import { joinPath, normalizeRelativePath } from '@/lib/path' +import type { StagedRuntimeUploadFileIdentity } from '../../../shared/runtime-upload-staging-contract' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { callRuntimeFileImportMutation, @@ -12,28 +14,48 @@ import { import { runtimePathExists } from './runtime-file-metadata-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 +/** Locates a staged file on the client so main can stream it without the renderer reading it. */ +export type RuntimeUploadSource = { + sourceRootPath: string + entryRelativePath: string + /** What staging observed; main refuses the upload if the source no longer matches. */ + expected: StagedRuntimeUploadFileIdentity +} +/** Stream one staged file to a temp path, then commit it; the temp path is always cleaned up. */ export async function uploadRuntimeFileWithoutClobber( session: RuntimeFileImportSession, worktreeId: string, relativePath: string, - contentBase64: string, + source: RuntimeUploadSource, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, expectedExecutionHostId?: 'local' | `ssh:${string}` ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { - await writeRuntimeBase64File( - session, - worktreeId, - tempRelativePath, - contentBase64, - expectedSshConnectionGeneration, - expectedSshTargetId, - expectedExecutionHostId - ) + session.assertCurrent() + // Why: main owns the file handle and the runtime socket, so it streams the + // body in slices; the renderer never holds the whole file. + try { + await window.api.fs.uploadExternalFileToRuntime({ + environmentId: session.target.environmentId, + sourceRootPath: source.sourceRootPath, + entryRelativePath: source.entryRelativePath, + expected: source.expected, + worktree: toRuntimeWorktreeSelector(worktreeId), + relativePath: tempRelativePath, + expectedSshTargetId, + expectedSshConnectionGeneration, + expectedExecutionHostId, + expectedEnvironmentPairingRevision: session.expectedEnvironmentPairingRevision, + expectedEnvironmentRuntimeId: session.expectedEnvironmentRuntimeId + }) + } catch (error) { + // Why: this surfaces in the import result as-is, and Electron wraps a + // main-process throw in "Error invoking remote method '…'". + throw new Error(extractIpcErrorMessage(error, 'Upload failed')) + } await callRuntimeFileImportMutation( session, 'files.commitUpload', @@ -64,50 +86,7 @@ export async function uploadRuntimeFileWithoutClobber( } } -async function writeRuntimeBase64File( - session: RuntimeFileImportSession, - worktreeId: string, - relativePath: string, - contentBase64: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: 'local' | `ssh:${string}` -): Promise { - if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - return - } - - for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64Chunk', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), - append: offset > 0, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - } -} - +/** Hidden sibling of the destination, so a failed upload never leaves a plausible-looking file. */ function makeRuntimeUploadTempPath(relativePath: string): string { const normalized = normalizeRelativePath(relativePath) const slashIndex = normalized.lastIndexOf('/') diff --git a/src/renderer/src/store/github/project-cache.ts b/src/renderer/src/store/github/project-cache.ts index df8ac361025..eb296633201 100644 --- a/src/renderer/src/store/github/project-cache.ts +++ b/src/renderer/src/store/github/project-cache.ts @@ -76,11 +76,11 @@ export function applyRowPatch( set((s) => { const entry = s.projectViewCache[cacheKey] if (!entry?.data) { - return {} + return s } const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) if (rowIndex === -1) { - return {} + return s } const rows = [...entry.data.rows] rows[rowIndex] = nextRow diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 72069802064..553ef5c1e13 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -153,7 +153,7 @@ export function startPullRequestLookup(args: { // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { skippedStaleLinkedPRLookup = true - return {} + return s } const updates = setGitHubPRResultCaches(s, { prCacheKey: cacheKey, @@ -174,7 +174,7 @@ export function startPullRequestLookup(args: { requestStartedEntry: requestStartedHostedReviewEntry }) didUpdatePRCache = updates.prCache !== undefined - return updates + return updates.prCache || updates.hostedReviewCache ? updates : s }) if (skippedStaleLinkedPRLookup) { return null diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index e1d02a09d3b..97a6d3b66f3 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -45,7 +45,7 @@ export const createWorkItemMutationActions = ( nextCache[key] = { ...entry, data: updatedItems } changed = true } - return changed ? { workItemsCache: nextCache } : {} + return changed ? { workItemsCache: nextCache } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-actions.ts b/src/renderer/src/store/slices/browser/browser-host-actions.ts index 2977717d38c..193dfdafe60 100644 --- a/src/renderer/src/store/slices/browser/browser-host-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-host-actions.ts @@ -24,7 +24,7 @@ export function createBrowserHostActions( closes, Date.now() ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, @@ -34,7 +34,7 @@ export function createBrowserHostActions( s.clientHostedBrowserCloseIntentsByEnvironment, { environmentId, browserPageIds, now: Date.now() } ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts index 1b4b3a70e9a..7ee75ac54a1 100644 --- a/src/renderer/src/store/slices/browser/browser-host-state.ts +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -153,7 +153,7 @@ export function browserImportStateForHostUpdate( hostId: ExecutionHostId, browserSessionImportState: BrowserSlice['browserSessionImportState'] ): Partial { - return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : state } export function getFallbackTabTypeForWorktree( diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index a2ed3f5bbb1..13beee05872 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -257,7 +257,7 @@ export function createBrowserHydrationActions( } } } - return {} + return s }) } } diff --git a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts index ec03690bf8e..9b79ec9adbc 100644 --- a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts @@ -133,13 +133,13 @@ export function createBrowserProfileImportActions( set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } - : {} + : s ) } catch { set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } return @@ -161,11 +161,11 @@ export function createBrowserProfileImportActions( detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } catch { /* best-effort — empty list is acceptable fallback */ - set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : s)) } } } diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts index e361958932c..4e7ef3e722d 100644 --- a/src/renderer/src/store/slices/commit-message-generation.ts +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -160,7 +160,7 @@ export const createCommitMessageGenerationSlice: StateCreator< set((state) => { const nextRecord = updater(state.commitMessageGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { commitMessageGenerationRecords: { @@ -184,6 +184,6 @@ export const createCommitMessageGenerationSlice: StateCreator< changed = true } } - return changed ? { commitMessageGenerationRecords: nextRecords } : {} + return changed ? { commitMessageGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts index 92e1eb9ea9f..4d28d7b7f1d 100644 --- a/src/renderer/src/store/slices/diff-comment-persistence.ts +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -239,13 +239,13 @@ export function mutateDiffComments( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) if (!target) { - return {} + return s } folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed return { @@ -256,16 +256,16 @@ export function mutateDiffComments( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) if (!target) { - return {} + return s } previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed const nextList: Worktree[] = repoList.map((w) => @@ -293,7 +293,7 @@ function rollback( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) if (!target || target.diffComments !== expectedCurrent) { - return {} + return s } return { folderWorkspaces: s.folderWorkspaces.map((workspace) => @@ -303,16 +303,16 @@ function rollback( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. if (!target) { - return {} + return s } // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. if (target.diffComments !== expectedCurrent) { - return {} + return s } const nextList: Worktree[] = repoList.map((w) => w.id === worktreeId ? { ...w, diffComments: previous } : w diff --git a/src/renderer/src/store/slices/empty-update-notifications.test.ts b/src/renderer/src/store/slices/empty-update-notifications.test.ts new file mode 100644 index 00000000000..692fa50b7d4 --- /dev/null +++ b/src/renderer/src/store/slices/empty-update-notifications.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { createTabsSliceMockApi } from './tabs-slice-test-harness' +import { browserImportStateForHostUpdate } from './browser/browser-host-state' +import { mutateDiffComments } from './diff-comment-persistence' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +createTabsSliceMockApi() + +describe('empty store updates', () => { + it('does not notify for missing tab actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel('missing', 'label') + before.setTabCustomLabel('missing', 'label') + before.setUnifiedTabColor('missing', null) + before.setTabViewMode('missing', 'chat') + before.toggleTabViewMode('missing') + before.pinTab('missing') + before.unpinTab('missing') + before.reorderUnifiedTabs('missing', []) + before.moveUnifiedTabToGroup('missing', 'missing') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for unchanged labels but publishes changed labels', () => { + const store = createTestStore() + const tab = store + .getState() + .createUnifiedTab('folder-workspace', 'terminal', { label: 'label' }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel(tab.id, 'label') + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.setTabLabel(tab.id, 'new label') + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState().getTab(tab.id)?.label).toBe('new label') + }) + + it('does not notify for rejected generation updates or empty pruning', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updateCommitMessageGenerationRecord('missing', () => null) + before.updatePullRequestGenerationRecord('missing', () => null) + before.pruneCommitMessageGenerationRecords(new Set()) + before.prunePullRequestGenerationRecords(new Set()) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for absent Jira issues, browser pages or diff comments', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.patchJiraIssue('MISSING-1', {}) + before.patchLinearIssue('missing', {}) + before.switchBrowserTabProfile('missing', null, 'persist:missing') + before.recordClientHostedBrowserCloseIntents([]) + before.clearClientHostedBrowserCloseIntents('missing', []) + mutateDiffComments(store.setState, 'missing', () => null) + store.setState((state) => browserImportStateForHostUpdate(state, 'runtime:other', null)) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 418b6648fa0..8b42bf3e56f 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -86,6 +86,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { hostedReviewCache: {}, prCache: {} } as unknown as Partial) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) resolveRefresh({ kind: 'found', pr: makePR({ number: 12, title: 'Stale exact linked PR' }), @@ -93,6 +95,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) await expect(request).resolves.toBeNull() + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index ef22da072d1..ba741de5cfb 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -15,6 +15,15 @@ describe('createGitHubSlice.patchWorkItem', () => { resetRemoteRuntimeMocks() }) + it('does not notify when a patch has no matching cached work item', () => { + const store = createTestStore() + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + store.getState().patchWorkItem('pr:missing', { title: 'Missing' }, 'repo-1') + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() + }) + it('can scope patches to one repo when different repos have the same work-item id', () => { const store = createTestStore() const repoOneItem = { diff --git a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts index 59e253e1823..16d765fe0d5 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts @@ -112,10 +112,14 @@ describe('hosted review cache race protection', () => { } } }) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) vi.setSystemTime(300) resolveFetch(olderReview) await expect(request).resolves.toEqual(olderReview) + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().hostedReviewCache[cacheKey]).toEqual({ data: newerReview, fetchedAt: 200, diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 53cdb00db55..cad350d986b 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -234,7 +234,7 @@ export const createHostedReviewSlice: StateCreator { const nextRecord = updater(state.pullRequestGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { pullRequestGenerationRecords: { @@ -312,6 +312,6 @@ export const createPullRequestGenerationSlice: StateCreator< changed = true } } - return changed ? { pullRequestGenerationRecords: nextRecords } : {} + return changed ? { pullRequestGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts index b9e763bf72e..6a4a2d68245 100644 --- a/src/renderer/src/store/slices/sparse-presets.ts +++ b/src/renderer/src/store/slices/sparse-presets.ts @@ -145,7 +145,7 @@ export const createSparsePresetsSlice: StateCreator { const existing = s.sparsePresetsByRepo[args.repoId] if (existing === undefined) { - return {} + return s } const without = existing.filter((preset) => preset.id !== saved.id) return { diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index b4f1d611b2f..f8736ed250b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -120,7 +120,7 @@ export function createTabsCreateActions( target.sourceGroupId ) if (!sourceGroup) { - return {} + return state } const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const currentGroups = state.groupsByWorktree[worktreeId] ?? [] diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts index 2cadf76365e..612bee0b538 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -25,19 +25,19 @@ export function createTabsDropActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } const isSplitDrop = Boolean(target.splitDirection) if (!isSplitDrop && tab.groupId === target.groupId) { - return {} + return state } const layout = state.layoutByWorktree[worktreeId] if ( @@ -51,7 +51,7 @@ export function createTabsDropActions( }) ) { // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts index 39a0dd38858..6e4134c6a1b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -53,7 +53,7 @@ export function createTabsFocusActions( found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) } if (!found) { - return {} + return state } const { tab, worktreeId } = found // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 42b8e3d7163..3ee06aa105c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -50,7 +50,7 @@ export function createTabsLabelActions( } } } - return {} + return state }) if (reordered && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') @@ -58,17 +58,24 @@ export function createTabsLabelActions( }, setTabLabel: (tabId, label) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? state) }, setTabViewMode: (tabId, mode) => { - set((state) => ({ - ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), - // Why the row too: viewMode is declared on both types and host-sync - // already writes it to the row. Only these local toggles skipped it, so - // readers had to OR the two indices to find out who owns the surface. - ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) - })) + set((state) => { + const tabPatch = patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) + const rowPatch = patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + if (!tabPatch && !rowPatch.tabsByWorktree) { + return state + } + return { + ...tabPatch, + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...rowPatch + } + }) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -81,7 +88,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' @@ -111,7 +118,7 @@ export function createTabsLabelActions( setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? state) if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -119,7 +126,7 @@ export function createTabsLabelActions( setUnifiedTabColor: (tabId, color) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? state) if (exists) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -130,7 +137,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => @@ -168,7 +175,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 4c651f613ac..8e8a51f5c0c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -22,16 +22,16 @@ export function createTabsMoveActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab if (tab.groupId === targetGroupId) { - return {} + return state } const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 28530219cce..435eadec4ec 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -23,14 +23,14 @@ export function createUpdatePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } // Why: the main process re-emits the same phase; skip no-op writes so the strip and panel don't re-render. const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some( (key) => patch[key] !== entry[key] ) if (!hasChange) { - return {} + return s } return { pendingWorktreeCreations: { @@ -51,7 +51,7 @@ export function createRemovePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } removedEntry = entry const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations @@ -90,7 +90,7 @@ export function createSetActivePendingWorktreeCreation( return (creationId) => { set((s) => { if (creationId !== null && !s.pendingWorktreeCreations[creationId]) { - return {} + return s } return { activePendingCreationId: creationId } }) diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts index fdf17d38f8f..35464523ba5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts @@ -261,7 +261,7 @@ export function applyHostedReviewLinkClear( nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo ) { - return {} + return s } return { ...(nextWorktrees !== s.worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 5e5c38a71e2..414a2ef206a 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -149,7 +149,7 @@ export function createUpdateWorktreeMeta( shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId, executionHostId)) ) { - return {} + return s } didApply = true const nextWorktrees = applyWorktreeUpdates( @@ -204,7 +204,7 @@ export function createUpdateWorktreeMeta( !cacheKey && !prCacheKey ) { - return {} + return s } const nextHostedReviewCache = diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts index 78254535106..8ae7be3ea8b 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts @@ -73,7 +73,7 @@ export function createUpdateWorktreesMeta( } return nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo - ? {} + ? s : { ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } diff --git a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts index 75d6059f309..d14392e72d9 100644 --- a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts +++ b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts @@ -14,7 +14,10 @@ export function createMigrateWorktreeIdentity( } // Why: invalidate pre-rename toast actions before publishing the new path, carrying the dismissal forward. migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId) - set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId)) + set((s) => { + const patch = buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId) + return Object.keys(patch).length > 0 ? patch : s + }) migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId) } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 31c7e8bbb37..84c85c45a3c 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -217,15 +217,15 @@ export function createSetActiveWorktree( pendingActivationTerminalPrepCancels.delete(worktreeId) set((s) => { if (s.activeWorktreeId !== worktreeId) { - return {} + return s } const tabs = s.tabsByWorktree[worktreeId] ?? [] if (tabs.length === 0) { - return {} + return s } const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) if (!allDead && !shouldTagTerminalTabs) { - return {} + return s } return { tabsByWorktree: { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts new file mode 100644 index 00000000000..7fdaea7999f --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../../worktrees-slice-test-harness' +import { makeWorktree } from '../../worktrees-slice-test-fixtures' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } +})) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice: vi.fn() +})) + +describe('worktree no-op notifications', () => { + it('keeps missing creation, recovery, activity, deletion and visit updates silent', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updatePendingWorktreeCreation('missing', { phase: 'fetching' }) + before.removePendingWorktreeCreation('missing') + before.setActivePendingWorktreeCreation('missing') + before.remountTerminalTabForRecovery('missing') + before.settleTerminalTabRecovery('missing', 1, 'success') + before.markWorktreeUnread('missing') + before.bumpWorktreeActivity('missing') + before.clearWorktreeDeleteState('missing') + before.seedActiveWorktreeLastVisitedIfMissing() + before.pruneLastVisitedTimestamps() + before.migrateWorktreeIdentity('missing-old', 'missing-new') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it.each(['local', 'ssh:test'] as const)( + 'keeps repeated %s deletion and visit updates silent', + (hostId) => { + const store = createTestStore() + const worktree = makeWorktree({ id: 'repo1::/path/wt', repoId: 'repo1', hostId }) + store.setState({ worktreesByRepo: { repo1: [worktree] } }) + const target = { id: worktree.id, hostId } + store.getState().markWorktreesQueuedForDeletion([target]) + store.getState().markWorktreeVisited(worktree.id, 100, hostId) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.markWorktreesQueuedForDeletion([target]) + before.markWorktreeVisited(worktree.id, 100, hostId) + before.markWorktreeVisited(worktree.id, 99, hostId) + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.markWorktreesDeleting([target]) + expect(listener).toHaveBeenCalledTimes(1) + const deleting = store.getState() + deleting.markWorktreesDeleting([target]) + expect(store.getState()).toBe(deleting) + expect(listener).toHaveBeenCalledTimes(1) + deleting.clearWorktreeDeleteState(worktree.id, hostId) + expect(listener).toHaveBeenCalledTimes(2) + } + ) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 78e0e397bca..66e6ddf5b51 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -55,7 +55,7 @@ export function createRemountTerminalTabForRecovery( const { admitted: _admitted, ...decline } = admission result = { remounted: false, ...decline } } - return {} + return s } const { worktreeId, index, tab } = location const nextTabs = s.tabsByWorktree[worktreeId].slice() @@ -110,12 +110,12 @@ export function createSettleTerminalTabRecovery( set((s) => { const location = locateTerminalTab(s.tabsByWorktree, tabId) if (!location) { - return {} + return s } const { worktreeId, index, tab } = location const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) if (!recovery) { - return {} + return s } const nextTabs = s.tabsByWorktree[worktreeId].slice() nextTabs[index] = { ...tab, recovery } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index 72d95b6a0a4..bea375b8292 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -59,7 +59,7 @@ export function createMarkWorktreeUnread( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree || worktree.isUnread) { - return {} + return s } shouldPersist = true const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { @@ -266,7 +266,7 @@ export function createBumpWorktreeActivity( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree) { - return {} + return s } shouldPersist = true // Why: skip sortEpoch bump for the active worktree — its PTY events are click side-effects (reorder-on-click bug, PR #209). diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 5cfc5eef3d2..7a9d94c89fd 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -29,7 +29,7 @@ export function createMarkWorktreeVisited( hostId: ownerHostId }) ?? 0 if (!(now > prev)) { - return {} + return s } return { lastVisitedAtByWorktreeId: { @@ -124,7 +124,7 @@ export function createPruneLastVisitedTimestamps( patch.activeWorkspaceExecutionHostId = null } } - return Object.keys(patch).length > 0 ? patch : {} + return Object.keys(patch).length > 0 ? patch : s }) } } @@ -137,12 +137,12 @@ export function createSeedActiveWorktreeLastVisitedIfMissing( set((s) => { const id = s.activeWorktreeId if (!id) { - return {} + return s } const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId const key = getWorktreeVisitKey(id, hostId) if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) { - return {} + return s } return { lastVisitedAtByWorktreeId: { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts index 30975b958e6..7e89bb2e34b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts @@ -78,7 +78,7 @@ export function createMarkWorktreesDeleting( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -113,7 +113,7 @@ export function createMarkWorktreesQueuedForDeletion( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -128,7 +128,7 @@ export function createClearWorktreeDeleteState( : worktreeId set((s) => { if (!s.deleteStateByWorktreeId[key]) { - return {} + return s } const next = { ...s.deleteStateByWorktreeId } delete next[key] diff --git a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts index d67a382a421..fc3abb5edd6 100644 --- a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts +++ b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts @@ -16,7 +16,7 @@ export function createTerminalDisownedPtySourceActions( markPtySourceDisowned: (ptyId) => { set((state) => state.disownedPtyIds[ptyId] - ? {} + ? state : { disownedPtyIds: { ...state.disownedPtyIds, [ptyId]: true } } ) } diff --git a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts index 89a67bd72ae..80de6b6899a 100644 --- a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts +++ b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts @@ -30,7 +30,7 @@ export function createTerminalEphemeralActions( markDefaultTerminalTabsApplied: (worktreeId) => set((s) => { if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { - return {} + return s } return { defaultTerminalTabsAppliedByWorktreeId: { @@ -70,7 +70,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchPromptByTabId[tabId] if (!current || current.failed) { - return {} + return s } return { nativeChatLaunchPromptByTabId: { @@ -83,7 +83,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchPrompt: (tabId) => { set((s) => { if (!s.nativeChatLaunchPromptByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchPromptByTabId } delete next[tabId] @@ -102,7 +102,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchDraftByTabId[tabId] if (!current || current.adopted) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -121,7 +121,7 @@ export function createTerminalEphemeralActions( current.createdAt !== resolution.createdAt || current.text !== resolution.text ) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -134,7 +134,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchDraftByTabId } delete next[tabId] @@ -168,7 +168,7 @@ export function createTerminalEphemeralActions( next ??= { ...s.lastTerminalInputAtByPaneKey } next[key] = at } - return next ? { lastTerminalInputAtByPaneKey: next } : {} + return next ? { lastTerminalInputAtByPaneKey: next } : s }) } }) @@ -227,7 +227,7 @@ export function createTerminalEphemeralActions( removeDeferredSshSessionId: (tabId) => set((s) => { if (!s.deferredSshSessionIdsByTabId[tabId]) { - return {} + return s } const next = { ...s.deferredSshSessionIdsByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 9b5cf8323c2..42ad3dbc659 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -30,7 +30,7 @@ export function createTerminalLayoutActions( set((s) => { const layout = s.terminalLayoutsByTabId[tabId] if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return {} + return s } return { terminalLayoutsByTabId: { diff --git a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts new file mode 100644 index 00000000000..4d68e867770 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + flushTerminalInputActivity, + resetTerminalInputActivityCoalescingForTests +} from '@/lib/terminal-input-activity-coalescing' +import { createTestStore, makeLayout } from '../slices/store-test-helpers' + +afterEach(resetTerminalInputActivityCoalescingForTests) + +describe('terminal no-op subscriber budget', () => { + it('does not publish missing-entry cleanup and restart actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + for (let i = 0; i < 25; i += 1) { + const s = store.getState() + s.replaceTerminalLayoutPanePtyId('missing', 'leaf', 'pty') + expect(s.consumeSuppressedPtyExit('missing')).toBe(false) + expect(s.consumePendingCodexPaneRestart('missing')).toBe(false) + s.clearCodexRestartNotice('missing') + s.dismissCodexRestartNotices(['missing']) + s.reopenCodexRestartPrompt('missing') + s.markNativeChatLaunchPromptFailed('missing') + s.clearNativeChatLaunchPrompt('missing') + s.markNativeChatLaunchDraftAdopted('missing') + s.resolveNativeChatLaunchDraft('missing', { text: 'draft', createdAt: 1 }) + s.clearNativeChatLaunchDraft('missing') + s.removeDeferredSshSessionId('missing') + } + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + }) + + it('publishes real mutations once and keeps repeated actions silent', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'draft', createdAt: 1 } as const + store.getState().seedNativeChatLaunchPrompt(draft) + store.getState().seedNativeChatLaunchDraft(draft) + store.getState().setTabLayout('tab', makeLayout()) + const listener = vi.fn() + store.subscribe(listener) + + const actions = [ + () => store.getState().markDefaultTerminalTabsApplied('folder-workspace'), + () => store.getState().markUnverifiedPtyLoss('tab'), + () => store.getState().markPtySourceDisowned('pty'), + () => store.getState().markNativeChatLaunchPromptFailed('tab'), + () => store.getState().markNativeChatLaunchDraftAdopted('tab'), + () => store.getState().resolveNativeChatLaunchDraft('tab', draft), + () => store.getState().replaceTerminalLayoutPanePtyId('tab', 'leaf', 'pty'), + () => store.getState().clearNativeChatLaunchPrompt('tab'), + () => store.getState().clearNativeChatLaunchDraft('tab') + ] + for (const action of actions) { + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + }) + + it('retains draft generations when stale resolutions arrive without notifying', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'new draft', createdAt: 2 } as const + store.getState().seedNativeChatLaunchDraft(draft) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, createdAt: 1 }) + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, text: 'old draft' }) + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().nativeChatLaunchDraftByTabId.tab).toBe(draft) + }) + + it('consumes real restart entries and leaves repeated consumes silent', () => { + const store = createTestStore() + store.getState().suppressPtyExit('pty') + store.getState().queueCodexPaneRestarts(['pty']) + const listener = vi.fn() + store.subscribe(listener) + + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(true) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(true) + expect(listener).toHaveBeenCalledTimes(2) + const before = store.getState() + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(false) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(false) + expect(listener).toHaveBeenCalledTimes(2) + expect(store.getState()).toBe(before) + }) + + it('drops a trailing input flush after pane teardown without publishing', () => { + const store = createTestStore() + store.getState().recordTerminalInput('tab:leaf', 1000) + store.getState().recordTerminalInput('tab:leaf', 1001) + store.setState({ lastTerminalInputAtByPaneKey: {} }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + flushTerminalInputActivity() + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().lastTerminalInputAtByPaneKey['tab:leaf']).toBeUndefined() + }) + + it('dismisses, reopens and clears restart notices without replaying no-op notifications', () => { + const store = createTestStore() + store + .getState() + .markCodexRestartNotices([ + { ptyId: 'pty', previousAccountLabel: 'old', nextAccountLabel: 'new' } + ]) + const listener = vi.fn() + store.subscribe(listener) + const actions = [ + () => store.getState().dismissCodexRestartNotices(['pty']), + () => store.getState().reopenCodexRestartPrompt('pty'), + () => store.getState().clearCodexRestartNotice('pty') + ] + for (const [index, action] of actions.entries()) { + if (index === 1) { + store.getState().queueCodexPaneRestarts(['pty']) + } + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + expect(store.getState().codexRestartNoticeByPtyId.pty).toBeUndefined() + expect(store.getState().pendingCodexPaneRestartIds.pty).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts index f1075bdb698..2b712b6ccdb 100644 --- a/src/renderer/src/store/terminals/terminal-restart-state.ts +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -21,7 +21,7 @@ export function createTerminalRestartActions( let wasSuppressed = false set((s) => { if (!s.suppressedPtyExitIds[ptyId]) { - return {} + return s } wasSuppressed = true const next = { ...s.suppressedPtyExitIds } @@ -68,7 +68,7 @@ export function createTerminalRestartActions( let wasQueued = false set((s) => { if (!s.pendingCodexPaneRestartIds[ptyId]) { - return {} + return s } wasQueued = true const next = { ...s.pendingCodexPaneRestartIds } @@ -144,7 +144,7 @@ export function createTerminalRestartActions( clearCodexRestartNotice: (ptyId) => { set((s) => { if (!s.codexRestartNoticeByPtyId[ptyId]) { - return {} + return s } const next = { ...s.codexRestartNoticeByPtyId } const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } @@ -175,7 +175,7 @@ export function createTerminalRestartActions( changed = true } if (!changed) { - return {} + return s } return { codexRestartNoticeByPtyId: next, @@ -187,7 +187,7 @@ export function createTerminalRestartActions( set((s) => { const notice = s.codexRestartNoticeByPtyId[ptyId] if (!notice?.restartRequested) { - return {} + return s } const { restartRequested: _restartRequested, ...kept } = notice const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index 0f684e3f8b7..af56050ef23 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -62,7 +62,7 @@ export function createTerminalStartupQueueActions( } set((s) => { if (s.pendingStartupByTabId[tabId] !== pending) { - return {} + return s } const next = { ...s.pendingStartupByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts index 9f381a02c4e..075d349a528 100644 --- a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts +++ b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts @@ -8,7 +8,7 @@ export function createTerminalUnverifiedPtyLossActions( markUnverifiedPtyLoss: (tabId) => { set((state) => state.unverifiedPtyLossTabIds[tabId] - ? {} + ? state : { unverifiedPtyLossTabIds: { ...state.unverifiedPtyLossTabIds, [tabId]: true } } ) } diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index dcf93839828..889e89b5103 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -126,6 +126,11 @@ export function createFileApi(): NonNullable['fs']> { }, importExternalPaths: async () => ({ results: [] }), stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + // Why: the web client has no local filesystem to stream from, so staging + // never yields a source for this to upload. + uploadExternalFileToRuntime: async () => { + throw new Error('Uploading local files is not supported in the web client') + }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 2b5194bfda8..949768e03b5 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -1,3 +1,4 @@ +import { getPiStateTitleBrand } from './pi-state-title-marker' import { AGY_AGENT_NAME_RE, CLAUDE_IDLE, @@ -67,6 +68,10 @@ function computeAgentLabel(title: string): string | null { ) { return 'Claude Code' } + const piStateBrand = getPiStateTitleBrand(title) + if (piStateBrand) { + return piStateBrand + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index 2526c94572f..df0a668621a 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -1,3 +1,4 @@ +import { rebrandPiStateTitle } from './pi-state-title-marker' import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' import type { AgentStatusEntry, AgentType } from './agent-status-types' import { @@ -157,6 +158,10 @@ export function normalizeCompatibleAgentTitleForOwner( ) { return title } + const stateTitle = rebrandPiStateTitle(title, ownerProfile.workingLabel) + if (stateTitle !== null) { + return stateTitle + } // Why: a π-branded title is the agent's own semantic session title (`π > - `; // Orca's injected extension writes the same shape). Swap only the BRAND for the owner's label // so the pane still reads as its launch owner (#6689, #7633, #9077) without discarding the diff --git a/src/shared/filesystem-import-result-types.ts b/src/shared/filesystem-import-result-types.ts index b211a01cdea..206570e9a33 100644 --- a/src/shared/filesystem-import-result-types.ts +++ b/src/shared/filesystem-import-result-types.ts @@ -1,3 +1,8 @@ +import type { + StagedRuntimeUploadEntry, + StagedRuntimeUploadSource +} from './runtime-upload-staging-contract' + export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' export type ResolveDroppedPathsResult = { @@ -25,25 +30,7 @@ export type ImportItemResult = reason: string } -export type StagedExternalImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedExternalImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: ImportSkipReason - } - | { - sourcePath: string - status: 'failed' - reason: string - } - -export type StagedExternalImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } +// Why: staging crosses IPC to the renderer and back into the streamer, so the +// shape lives in shared and every layer names the same type. +export type StagedExternalImportSource = StagedRuntimeUploadSource +export type StagedExternalImportEntry = StagedRuntimeUploadEntry diff --git a/src/shared/omp-owner-state-title.test.ts b/src/shared/omp-owner-state-title.test.ts new file mode 100644 index 00000000000..c06e4dbfe78 --- /dev/null +++ b/src/shared/omp-owner-state-title.test.ts @@ -0,0 +1,51 @@ +import { getPiCompatibleTitleSeparatorStatus } from './pi-compatible-synthetic-title' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' +import { normalizeCompatibleAgentTitleForOwner } from './agent-title-owner' +import { clearPiStateWorkingMarker } from './pi-state-title-marker' + +const transcript = readFileSync( + join(__dirname, '..', 'main', 'runtime', '__fixtures__', 'omp-native-title-win32.txt'), + 'utf8' +) +// oxlint-disable-next-line no-control-regex -- The fixture retains actual OSC control bytes. +const titles = [...transcript.matchAll(/\x1b\]0;([^\x07]+)\x07/g)].map((match) => match[1]) + +describe('owner-rewritten OMP titles from captured upstream output', () => { + it('contains the six upstream state frames', () => expect(titles).toHaveLength(6)) + it.each( + titles.map((title, index) => ({ + title, + state: index < 2 ? 'working' : index < 4 ? 'idle' : 'permission' + })) + )('preserves $state and label for $title', ({ title, state }) => { + for (const prefix of ['', 'zsh | ', 'tmux: ']) { + const wrapped = prefix + title + expect(detectAgentStatusFromTitle(wrapped)).toBe(state) + const owned = normalizeCompatibleAgentTitleForOwner(wrapped, 'omp', { ownerIsLaunch: true }) + expect(owned).toBe(prefix + title.replace('π', 'OMP')) + expect(getAgentLabel(owned)).toBe('OMP') + expect(detectAgentStatusFromTitle(owned)).toBe(state) + expect(getPiCompatibleTitleSeparatorStatus(owned)).toBe(state) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'omp')).toBe(owned) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'pi')).toBe( + prefix + title.replace('π', 'Pi') + ) + if (state === 'working') { + expect(detectAgentStatusFromTitle(clearPiStateWorkingMarker(owned) ?? '')).toBe('idle') + } + } + }) + it.each([ + 'omp-harness ready', + '/tmp/OMP : file', + 'lowercase omp : note', + 'Pi: legacy', + 'OMP ready' + ])('does not rewrite neutral or legacy title %s as a working marker', (title) => { + expect(clearPiStateWorkingMarker(title)).toBeNull() + expect(detectAgentStatusFromTitle(title)).not.toBe('working') + }) +}) diff --git a/src/shared/pi-compatible-synthetic-title.ts b/src/shared/pi-compatible-synthetic-title.ts index 7b99235811d..0e9e5d2b196 100644 --- a/src/shared/pi-compatible-synthetic-title.ts +++ b/src/shared/pi-compatible-synthetic-title.ts @@ -1,3 +1,5 @@ +import { getPiStateTitleStatus } from './pi-state-title-marker' + export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP' export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle' @@ -71,6 +73,10 @@ export function isLegacyPiCompatibleTitle(title: string): boolean { export function getPiCompatibleTitleSeparatorStatus( title: string ): PiCompatibleSyntheticAgentStatus | null { + const nativeState = getPiStateTitleStatus(title) + if (nativeState) { + return nativeState + } // Why: a spinner anywhere means the agent is working, and that outranks the separator — // the frame is drawn over the idle separator position while a turn runs. if (containsBrailleSpinner(title)) { diff --git a/src/shared/pi-state-title-marker.ts b/src/shared/pi-state-title-marker.ts index 7f6ece71bd6..3d2ed19a759 100644 --- a/src/shared/pi-state-title-marker.ts +++ b/src/shared/pi-state-title-marker.ts @@ -27,15 +27,17 @@ function escapeForCharacterClass(marker: string): string { return marker.replace(/[\\\]^-]/g, '\\$&') } -// Why: `π` must sit at a token boundary so wrapper prefixes of any shape (`zsh | π : cwd`, +// Why: the brand must sit at a token boundary so wrapper prefixes (`zsh | OMP : cwd`, // `tmux: π : cwd`) still expose the marker, and whitespace must separate the marker so the // legacy no-space `π: cwd` disabled title keeps its historical idle classification. const PI_STATE_TITLE_RE = new RegExp( - `(?:^|[\\s|])π[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, + `(?:^|[\\s|])(π|Pi|OMP)[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, 'u' ) type PiStateTitleMatch = { + brand: string + brandIndex: number marker: PiStateMarker markerIndex: number } @@ -49,8 +51,14 @@ function matchPiStateTitle(title: string): PiStateTitleMatch | null { if (!match) { return null } + const marker = match[2] + if (marker !== ':' && marker !== '!' && marker !== '>') { + return null + } return { - marker: match[1] as PiStateMarker, + brand: match[1], + brandIndex: match.index + match[0].indexOf(match[1]), + marker, markerIndex: match.index + match[0].length - 1 } } @@ -73,3 +81,20 @@ export function clearPiStateWorkingMarker(title: string): string | null { } return `${title.slice(0, match.markerIndex)}${PI_IDLE_MARKER}${title.slice(match.markerIndex + 1)}` } + +/** The state marker owns identity too; its label may mention another agent. */ +export function getPiStateTitleBrand(title: string): 'Pi' | 'OMP' | null { + const match = matchPiStateTitle(title) + return match ? (match.brand === 'OMP' ? 'OMP' : 'Pi') : null +} + +/** Rebrand only the protocol prefix, preserving wrappers and the opaque session label. */ +export function rebrandPiStateTitle(title: string, brand: string): string | null { + const match = matchPiStateTitle(title) + if (!match) { + return null + } + return ( + title.slice(0, match.brandIndex) + brand + title.slice(match.brandIndex + match.brand.length) + ) +} diff --git a/src/shared/runtime-upload-staging-contract.ts b/src/shared/runtime-upload-staging-contract.ts new file mode 100644 index 00000000000..19a3d2f6261 --- /dev/null +++ b/src/shared/runtime-upload-staging-contract.ts @@ -0,0 +1,50 @@ +import type { SshMutationExpectation } from './ssh-types' + +export type RuntimeUploadSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + +/** + * What staging observed about a file, so the uploader can refuse a source that + * was swapped between the two calls. Size alone misses a same-size replacement. + */ +export type StagedRuntimeUploadFileIdentity = { + byteLength: number + /** 0 when the filesystem does not report one; compared only when both sides have it. */ + inode: number + deviceId: number + modifiedAtMs: number +} + +export type StagedRuntimeUploadEntry = + | { relativePath: string; kind: 'directory' } + // Why: file bodies are streamed in slices at upload time, so staging carries + // identity the uploader re-checks against the handle it actually reads. + | ({ relativePath: string; kind: 'file' } & StagedRuntimeUploadFileIdentity) + +export type StagedRuntimeUploadSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedRuntimeUploadEntry[] + } + | { sourcePath: string; status: 'skipped'; reason: RuntimeUploadSkipReason } + | { sourcePath: string; status: 'failed'; reason: string } + +export type StageRuntimeUploadResult = { sources: StagedRuntimeUploadSource[] } + +/** Renderer → main request to pump one staged file's bytes to the runtime. */ +export type RuntimeUploadFileStreamRequest = { + environmentId: string + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + /** Identity staging recorded; a source that no longer matches is refused, not streamed. */ + expected: StagedRuntimeUploadFileIdentity + worktree: string + /** Destination path on the runtime, relative to the worktree. */ + relativePath: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} & SshMutationExpectation diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 9ea733e5832..0b2aecfb67e 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -20,8 +20,11 @@ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' -/** Local admission refused the frame before any transport was involved. */ +/** Local admission refused the frame before any transport was involved. Two + * strings rather than one provider-neutral marker because both are already + * durable journal reasons; rewording either would relabel rows on disk. */ export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' +export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch queue is full' export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) @@ -45,6 +48,8 @@ export function dispatchRejectionWasTransportWriteFailure( */ export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { return ( - dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL + dispatchRejectionWasTransportWriteFailure(reason) || + reason === DISPATCH_REJECTED_QUEUE_FULL || + reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL ) } diff --git a/tests/e2e/omp-title-marker.spec.ts b/tests/e2e/omp-title-marker.spec.ts new file mode 100644 index 00000000000..4f2db0ddbd9 --- /dev/null +++ b/tests/e2e/omp-title-marker.spec.ts @@ -0,0 +1,43 @@ +import { writeFile } from 'node:fs/promises' +import { buildShellCommandFromArgv } from '../../src/shared/tui-agent-startup-shell' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +test('OMP spaced-colon title renders working and clears on idle', async ({ + orcaPage +}, testInfo) => { + test.skip( + process.platform === 'win32', + 'POSIX title replay; Windows formatter bytes have separate coverage' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const script = testInfo.outputPath('title-replay.cjs') + await writeFile( + script, + ` +process.stdout.write('\\x1b]0;OMP : Image review\\x07') +process.stdin.on('data', () => process.stdout.write('\\x1b]0;OMP > Image review\\x07')) +` + ) + await execInTerminal( + orcaPage, + ptyId, + buildShellCommandFromArgv([process.execPath, script], 'posix') + ) + const working = orcaPage.locator('[aria-label="Working"]') + await expect(working.first()).toBeVisible({ timeout: 15000 }) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-working.png') }) + await sendToTerminal(orcaPage, ptyId, '\r') + await expect(working).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-idle.png') }) +}) diff --git a/tests/tools/omp-history-title-smoke.mjs b/tests/tools/omp-history-title-smoke.mjs new file mode 100644 index 00000000000..7c5d84aaa2c --- /dev/null +++ b/tests/tools/omp-history-title-smoke.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +assert.ok(process.argv[2], 'Pass a read-only OMP checkout') +const orcaRoot = fileURLToPath(new URL('../../', import.meta.url)) +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-history-title-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +for (const [key, value] of Object.entries({ + XDG_CONFIG_HOME: 'config', + XDG_DATA_HOME: 'data', + XDG_STATE_HOME: 'state', + XDG_CACHE_HOME: 'cache' +})) { + process.env[key] = join(scratch, value) +} +for (const key of [ + 'OMP_CODING_AGENT_DIR', + 'PI_CODING_AGENT_DIR', + 'OMP_PROFILE', + 'PI_PROFILE', + 'PI_CONFIG_DIR', + 'PI_CONFIG_FILES' +]) { + delete process.env[key] +} +await mkdir(process.env.HOME, { recursive: true }) +const source = (root, path) => pathToFileURL(join(resolve(root), path)).href +const { SessionManager } = await import( + source(process.argv[2], 'packages/coding-agent/src/session/session-manager.ts') +) +const { parseMessageGraphSessionFile } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-graph-parsers.ts') +) +const { createSessionParseStats, parseAgentSessionFileCached } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-parse-cache.ts') +) +const stats = createSessionParseStats() +const manager = SessionManager.create(scratch, join(scratch, 'sessions')) +try { + manager.appendMessage({ role: 'user', content: 'Original first prompt', timestamp: Date.now() }) + await manager.ensureOnDisk() + await manager.flush() + const candidate = async () => { + const details = await stat(manager.getSessionFile()) + return { + agent: 'omp', + codexHome: null, + file: { + path: manager.getSessionFile(), + mtimeMs: details.mtimeMs, + modifiedAt: details.mtime.toISOString(), + sizeBytes: details.size + } + } + } + const initial = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(initial.title, 'Original first prompt') + await manager.setSessionName('Explicit renamed conversation', 'user') + await manager.flush() + const path = manager.getSessionFile() + const details = await stat(path) + const parsed = await parseMessageGraphSessionFile( + 'omp', + { path, mtimeMs: details.mtimeMs, modifiedAt: details.mtime.toISOString() }, + process.platform + ) + const refreshed = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(parsed?.title, manager.getSessionName()) + assert.equal(refreshed?.title, manager.getSessionName()) + assert.equal(stats.fullParses, 1) + assert.equal(stats.incremental, 1) + assert.equal(initial.title, 'Original first prompt') + const reused = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(reused, refreshed) + console.log( + JSON.stringify({ + actualOmpPersistence: true, + renamedTitlePreserved: true, + cachedRenamePreserved: true, + unchangedSnapshotReused: true, + fullParses: stats.fullParses, + incrementalParses: stats.incremental, + modelCalls: 0 + }) + ) +} finally { + await manager.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/omp-native-title-capture.mjs b/tests/tools/omp-native-title-capture.mjs new file mode 100644 index 00000000000..9f4be2cc7c0 --- /dev/null +++ b/tests/tools/omp-native-title-capture.mjs @@ -0,0 +1,18 @@ +// Run under Bun through capture-agent-pty-transcript.mjs; sourceRoot is read-only. +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const sourceRoot = process.argv[2] +if (!sourceRoot) { + throw new Error('Expected path to the read-only oh-my-pi checkout') +} +const { buildTerminalTitleWithState } = await import( + pathToFileURL(resolve(sourceRoot, 'packages/coding-agent/src/utils/title-generator.ts')).href +) +for (const state of ['working', 'idle', 'attention']) { + for (const label of ['Run a long task', 'release | π : note | OMP ! action required ✦']) { + // Exercise upstream's explicit Windows argument, independently of the capture host OS. + const title = buildTerminalTitleWithState(label, state, 0, true, 'win32') + process.stdout.write(`\x1b]0;${title}\x07`) + } +} diff --git a/tests/tools/omp-picker-search-rendered/README.md b/tests/tools/omp-picker-search-rendered/README.md new file mode 100644 index 00000000000..653a7bd1c33 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/README.md @@ -0,0 +1,17 @@ +# OMP picker project-name search (#14319) + +Run `ORCA_BACKGROUND_LAUNCH=1 node tests/tools/omp-picker-search-rendered/run.mjs`. +Uses production AgentCombobox, the production catalog and canonical CSS in a hidden +Electron renderer. Rebuilds the existing background-launch harness before execution; +all windows must remain invisible and unfocused. No dependency install is needed. + +The probe types `oh-my-pi`, captures the resulting OMP row over CDP, selects it, +asserts the callback receives `omp`, and checks `oh my pi` too. Reports/screenshots +are local under `.bench-fixtures/omp-picker-search-*`. Before proof uses the baseline +catalog without search aliases and `ORCA_OMP_PICKER_BASELINE=1`, expecting no match. + +Available agents are supplied by the fixture. This does not exercise local/SSH/WSL +PATH detection, disabled-agent settings, terminal launch, or a full workspace form. +Search only ranks entries supplied by each caller; aliases cannot introduce an +agent absent from that list. The broader missing-agent explanation in #14319 is +separate from this reproduced project-name search defect. diff --git a/tests/tools/omp-picker-search-rendered/fixture.css b/tests/tools/omp-picker-search-rendered/fixture.css new file mode 100644 index 00000000000..9c08bc0261c --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.css @@ -0,0 +1,4 @@ +@import '../../../src/renderer/src/assets/main.css'; +@source './fixture.tsx'; +@source '../../../src/renderer/src/components/agent/AgentCombobox.tsx'; +@source '../../../src/renderer/src/components/ui'; diff --git a/tests/tools/omp-picker-search-rendered/fixture.tsx b/tests/tools/omp-picker-search-rendered/fixture.tsx new file mode 100644 index 00000000000..b318cdee827 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.tsx @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { createRoot } from 'react-dom/client' +import AgentCombobox from '../../../src/renderer/src/components/agent/AgentCombobox' +import { getAgentCatalog } from '../../../src/renderer/src/lib/agent-catalog' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import './fixture.css' +const baseline = new URLSearchParams(window.location.search).get('baseline') === '1' +const agents = getAgentCatalog().map((agent) => + baseline && agent.id === 'omp' ? { ...agent, searchAliases: [] } : agent +) +function App() { + const [selected, setSelected] = useState(null) + return ( +
+

Agent picker

+ +

Selected agent: {selected ?? 'none'}

+
+ ) +} +const root = document.getElementById('root') +if (root) { + createRoot(root).render() +} diff --git a/tests/tools/omp-picker-search-rendered/index.html b/tests/tools/omp-picker-search-rendered/index.html new file mode 100644 index 00000000000..44793ff9830 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/tools/omp-picker-search-rendered/run.mjs b/tests/tools/omp-picker-search-rendered/run.mjs new file mode 100644 index 00000000000..14bee4a6752 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/run.mjs @@ -0,0 +1,93 @@ +import { _electron as electron, expect } from '@stablyai/playwright-test' +import { build as buildMain } from 'esbuild' +import { build as buildRenderer } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Requires ORCA_BACKGROUND_LAUNCH=1') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const parent = path.join(root, '.bench-fixtures') +mkdirSync(parent, { recursive: true }) +const output = mkdtempSync(path.join(parent, 'omp-picker-search-')) +const main = path.join(output, 'main.cjs') +await buildMain({ + entryPoints: [path.join(root, 'tests/tools/benchmarks/spinner-rendering/main.ts')], + outfile: main, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'] +}) +await buildRenderer({ + configFile: false, + root: import.meta.dirname, + base: './', + logLevel: 'silent', + plugins: [react(), tailwindcss()], + resolve: { alias: { '@': path.join(root, 'src/renderer/src') } }, + build: { outDir: path.join(output, 'renderer'), emptyOutDir: true } +}) +const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env +const app = await electron.launch({ args: [main], env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' } }) +const report = { + scope: + 'Production AgentCombobox and agent catalog in hidden Electron; supplied available agents, no PATH detection or terminal launch.' +} +try { + const page = await app.firstWindow() + const errors = [] + page.on('pageerror', (error) => { + errors.push(error.message) + console.error(error) + }) + const baseline = process.env.ORCA_OMP_PICKER_BASELINE === '1' + const fixtureUrl = pathToFileURL(path.join(output, 'renderer/index.html')) + if (baseline) { + fixtureUrl.searchParams.set('baseline', '1') + } + await page.goto(fixtureUrl.href) + await page.locator('button[role=combobox]').click() + const search = page.getByPlaceholder('Search agents...') + await search.fill('oh-my-pi') + await expect( + baseline + ? page.getByText('No agents match your search.') + : page.getByRole('option', { name: 'OMP', exact: true }) + ).toBeVisible() + await page.evaluate(async () => { + await Promise.all( + document.getAnimations().map((animation) => animation.finished.catch(() => {})) + ) + }) + const cdp = await page.context().newCDPSession(page) + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }) + writeFileSync( + path.join(output, baseline ? 'before.png' : 'after.png'), + Buffer.from(data, 'base64') + ) + if (!baseline) { + await page.getByRole('option', { name: 'OMP', exact: true }).click() + await expect(page.getByText('Selected agent: omp', { exact: true })).toBeVisible() + await expect(search).toBeHidden() + await page.locator('button[role=combobox]').click() + await search.fill('oh my pi') + await expect(page.getByRole('option', { name: 'OMP', exact: true })).toBeVisible() + } + report.baseline = baseline + expect(errors).toEqual([]) + report.windows = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(report.windows.every((window) => !window.visible && !window.focused)).toBe(true) +} finally { + writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`) + console.log(`OMP picker search evidence: ${output}`) + await app.close() +}