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