diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 290d4553a51c..0cbb0f2087f5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -705,7 +705,7 @@ jobs: # after the last shard. Deliberately absent from verify's needs for the same reason. unit_selection_evidence: needs: [test] - if: ${{ !cancelled() && needs.test.result == 'success' }} + if: ${{ !cancelled() && (needs.test.result == 'success' || needs.test.result == 'failure') }} uses: ./.github/workflows/unit-selection-evidence.yml # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix diff --git a/.github/workflows/terminal-perf.yml b/.github/workflows/terminal-perf.yml index 83ada43d2cf9..2f3a37524ebc 100644 --- a/.github/workflows/terminal-perf.yml +++ b/.github/workflows/terminal-perf.yml @@ -68,25 +68,60 @@ jobs: - name: Install native build tools and xvfb run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh + - name: Select dependency preparation + id: install-mode + shell: bash + env: + RUNNER_KIND: ${{ runner.environment }} + JOB_CONTAINER: ${{ job.container.id }} + run: | + node <<'NODE' + const fs = require('node:fs') + const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')) + const actionPath = '.github/actions/install-node-dependencies/action.yml' + const action = fs.existsSync(actionPath) ? fs.readFileSync(actionPath, 'utf8') : '' + const inputs = action.split(/^inputs:[ \t]*\r?$/m)[1]?.split(/^\S/m)[0] ?? '' + const shared = process.env.RUNNER_KIND === 'github-hosted' && !process.env.JOB_CONTAINER && + process.env.RUNNER_OS === 'Linux' && process.env.RUNNER_ARCH === 'X64' && + manifest.engines?.node === '24' && typeof manifest.packageManager === 'string' && + manifest.packageManager.split('+')[0] === 'pnpm@12.8.1' && + manifest.scripts?.postinstall === 'node config/scripts/rebuild-native-deps.mjs' && + /^ native-runtime:/m.test(inputs) && /^ cache-pnpm-store-lookup-only:/m.test(inputs) && + fs.existsSync('.github/actions/prepare-native-runtime/action.yml') && + fs.existsSync('config/scripts/ensure-native-runtime.mjs') + fs.appendFileSync(process.env.GITHUB_OUTPUT, `shared=${shared}\n`) + NODE + + - name: Prepare current dependencies + if: steps.install-mode.outputs.shared == 'true' + uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + cache-electron-package: 'true' + cache-pnpm-store-lookup-only: 'true' + - name: Setup pnpm + if: steps.install-mode.outputs.shared != 'true' uses: pnpm/setup@v2 with: install: false - name: Setup Node.js + if: steps.install-mode.outputs.shared != 'true' uses: actions/setup-node@v6 with: node-version-file: package.json cache: pnpm - # Why: this scheduled/manual workflow uses the same native install path as - # PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py. + # Historical refs can lack the shared action; retain their original install path. - name: Use external node-gyp to avoid pnpm's bundled copy + if: steps.install-mode.outputs.shared != 'true' run: | npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - name: Install dependencies + if: steps.install-mode.outputs.shared != 'true' run: pnpm install --frozen-lockfile - name: Build Electron app for terminal perf diff --git a/.gitignore b/.gitignore index dbb74b9c9504..ffa4bc4f600e 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md +!docs/reference/jcode-hook-events.md !docs/reference/linux-glibc-compatibility.md !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md diff --git a/cloud/apps/push/src/durable-push-payload-parse-budget.test.ts b/cloud/apps/push/src/durable-push-payload-parse-budget.test.ts new file mode 100644 index 000000000000..f04c6daadcd3 --- /dev/null +++ b/cloud/apps/push/src/durable-push-payload-parse-budget.test.ts @@ -0,0 +1,244 @@ +import { createHash } from 'node:crypto' +import { afterEach, expect, it, vi } from 'vitest' +import { DurablePushStore } from './durable-push-store.js' +import { buildPushDelivery } from './push-delivery-message.js' +import type { PushDatabase } from './push-database.js' +import { + cleanupDurablePushFixtures, + fixture, + notification +} from './durable-push-store.test-fixture.js' + +type QueryCall = { sql: string; params?: unknown[] } + +afterEach(async () => { + vi.restoreAllMocks() + await cleanupDurablePushFixtures() +}) + +function traceDatabase( + database: PushDatabase, + calls: QueryCall[], + errors: unknown[] +): PushDatabase { + return { + dialect: database.dialect, + query: async (sql, params) => { + calls.push({ sql, params }) + try { + return await database.query(sql, params) + } catch (error) { + errors.push(error) + throw error + } + }, + transaction: (run) => database.transaction((tx) => run(traceDatabase(tx, calls, errors))), + lockQuotaScope: (key) => database.lockQuotaScope(key), + tryLockScope: (key) => database.tryLockScope(key), + tryLockSharedScope: (key) => database.tryLockSharedScope(key), + close: () => database.close() + } +} + +function payloadHash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +it('parses each leased row once with exact complete payload, serialized key order and SQL sequence', async () => { + const { db, store, clock } = await fixture() + const input = { + ...notification(1), + body: 'Unicode: 🐋\ud800', + extra: { first: [null, false, 3], next: { z: 'last', a: 'first' } } + } + await store.accept('host', 'phone', input) + const [row] = await db.query('SELECT * FROM push_delivery_batches') + if (!row) { + throw new Error('Missing delivery row') + } + const payload = String(row.payload_json) + const calls: QueryCall[] = [] + const errors: unknown[] = [] + const owner = new DurablePushStore(traceDatabase(db, calls, errors), clock) + const parse = vi.spyOn(JSON, 'parse') + const delivery = await owner.claim() + expect(delivery).toEqual({ + id: row.batch_id, + registrationId: 'phone', + hostFingerprint: 'host', + notification: input, + expiresAt: 1_300_000, + lease: expect.any(String), + attempts: 1 + }) + expect(JSON.stringify(delivery?.notification)).toBe(payload) + expect(payloadHash(delivery?.notification)).toBe(payloadHash(input)) + if (!delivery) { + throw new Error('Missing delivery') + } + const published = buildPushDelivery(delivery) + const expected = buildPushDelivery({ ...delivery, notification: input }) + expect(JSON.stringify(published)).toBe(JSON.stringify(expected)) + expect(payloadHash(published)).toBe(payloadHash(expected)) + expect(calls.map(({ sql }) => sql.replace(/\s+/g, ' ').trim())).toEqual([ + `SELECT * FROM push_delivery_batches WHERE state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND due_at > ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.state = 'pending' AND busy.lease_until > 0 AND busy.lease_until > ?) ORDER BY due_at, created_at, batch_id LIMIT 1${db.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : ''}`, + "SELECT (SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND expires_at > ? AND due_at > ? ORDER BY due_at, created_at, batch_id LIMIT 1) AS head, EXISTS (SELECT 1 FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND lease_until > 0 AND lease_until > ?) AS busy", + 'SELECT notification_seq FROM push_dismissed_events WHERE host_fingerprint = ? AND notification_epoch = ? AND notification_id = ?', + 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?' + ]) + expect(calls[1]?.params).toEqual(['phone', clock(), clock() - 300_000, 'phone', clock()]) + expect(calls[2]?.params).toEqual(['host', 'epoch', 'notification-1']) + expect(calls[3]?.params).toEqual([delivery?.lease, clock() + 30_000, row.batch_id]) + expect(errors).toEqual([]) + expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(1) +}) + +it('keeps concurrent device claims separate and returns fresh payload objects', async () => { + const { store } = await fixture() + const input = notification(1) + await store.accept('host', 'phone-a', input) + await store.accept('host', 'phone-b', input) + const payload = JSON.stringify(input) + const parse = vi.spyOn(JSON, 'parse') + const claims = await Promise.all(Array.from({ length: 4 }, () => store.claim())) + const delivered = claims.filter((claim) => claim !== null) + expect(delivered).toHaveLength(2) + expect(delivered.map((claim) => claim.registrationId).sort()).toEqual(['phone-a', 'phone-b']) + expect(delivered.every((claim) => JSON.stringify(claim.notification) === payload)).toBe(true) + expect(delivered[0]?.notification).not.toBe(delivered[1]?.notification) + expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(2) +}) + +it('reads changed retry bytes and a later writer update without carrying a parsed result across calls', async () => { + const { db, store, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + const first = await store.claim() + if (!first) { + throw new Error('Missing first delivery') + } + first.notification.body = 'provider changed this retry' + await store.finish(first, 1000) + advance(1000) + const retriedPayload = JSON.stringify(first.notification) + const parse = vi.spyOn(JSON, 'parse') + const retry = await store.claim() + expect(retry).toEqual({ ...first, lease: expect.any(String), attempts: 2 }) + expect(retry?.lease).not.toBe(first.lease) + expect(retry?.notification).not.toBe(first.notification) + expect(JSON.stringify(retry?.notification)).toBe(retriedPayload) + expect(payloadHash(retry?.notification)).toBe(payloadHash(first.notification)) + const retryParses = parse.mock.calls.filter(([value]) => value === retriedPayload).length + if (!retry) { + throw new Error('Missing retry delivery') + } + await store.finish(retry, 1000) + const changed = { ...notification(1), body: 'fresh database row', title: 'Changed' } + const changedPayload = JSON.stringify(changed) + await db.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [ + changedPayload, + first.id + ]) + advance(1000) + const fresh = await store.claim() + expect(fresh).toEqual({ ...retry, notification: changed, lease: expect.any(String), attempts: 3 }) + expect(JSON.stringify(fresh?.notification)).toBe(changedPayload) + expect(payloadHash(fresh?.notification)).toBe(payloadHash(changed)) + expect(retry.notification.body).toBe('provider changed this retry') + expect(retryParses).toBe(1) + expect(parse.mock.calls.filter(([value]) => value === changedPayload)).toHaveLength(1) +}) + +it('keeps dismissed alerts on the original single-parse delete path without leasing', async () => { + const { db, store, clock } = await fixture() + const input = notification(1) + await store.accept('host', 'phone', input) + await db.query( + 'INSERT INTO push_dismissed_events(host_fingerprint, notification_epoch, notification_id, notification_seq, created_at) VALUES (?, ?, ?, ?, ?)', + ['host', 'epoch', input.notificationId, 1, clock()] + ) + const calls: QueryCall[] = [] + const parse = vi.spyOn(JSON, 'parse') + expect(await new DurablePushStore(traceDatabase(db, calls, []), clock).claim()).toBeNull() + expect(await store.pendingCount('phone')).toBe(0) + expect(calls.at(-1)?.sql).toBe('DELETE FROM push_delivery_batches WHERE batch_id = ?') + expect(calls.some(({ sql }) => sql.startsWith('UPDATE'))).toBe(false) + expect(parse.mock.calls.filter(([value]) => value === JSON.stringify(input))).toHaveLength(1) +}) + +it('preserves the existing trust boundary for an object missing notification fields', async () => { + const { db, store } = await fixture() + await store.accept('host', 'phone', notification(1)) + await db.query('UPDATE push_delivery_batches SET payload_json = ?', ['{}']) + const parse = vi.spyOn(JSON, 'parse') + const delivery = await store.claim() + expect(delivery?.notification).toEqual({}) + expect(JSON.stringify(delivery?.notification)).toBe('{}') + expect(parse.mock.calls.filter(([value]) => value === '{}')).toHaveLength(1) +}) + +it.each(['not JSON', 'undefined', 'null', '[]'])( + 'preserves invalid payload rejection and rolls back the lease for %s', + async (payload) => { + const { db, store } = await fixture() + await store.accept('host', 'phone', notification(1)) + await db.query('UPDATE push_delivery_batches SET payload_json = ?', [payload]) + const [before] = await db.query('SELECT * FROM push_delivery_batches') + const parse = vi.spyOn(JSON, 'parse') + let caught: unknown + try { + await store.claim() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Error) + const index = parse.mock.calls.findIndex(([value]) => value === payload) + expect(index).toBeGreaterThanOrEqual(0) + if (payload === 'not JSON' || payload === 'undefined') { + expect(caught).toBe(parse.mock.results[index]?.value) + expect(caught).toBeInstanceOf(SyntaxError) + } else { + expect(caught).toMatchObject({ message: 'invalid_push_delivery_payload' }) + } + expect(await db.query('SELECT * FROM push_delivery_batches')).toEqual([before]) + expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(1) + } +) + +it('preserves the exact database UPDATE error and retries with a fresh payload after rollback', async () => { + const { db, store, clock } = await fixture() + await store.accept('host', 'phone', notification(1)) + const [before] = await db.query('SELECT * FROM push_delivery_batches') + if (!before) { + throw new Error('Missing delivery row') + } + const originalQuery = db.query.bind(db) + const errors: unknown[] = [] + // SQLite raises a native error in the real transaction; PostgreSQL uses its real constraint. + await originalQuery( + db.dialect === 'sqlite' + ? "CREATE TRIGGER deny_lease BEFORE UPDATE ON push_delivery_batches BEGIN SELECT RAISE(FAIL, 'deny_lease'); END" + : 'ALTER TABLE push_delivery_batches ADD CONSTRAINT deny_lease CHECK (lease_until = 0)' + ) + const owner = new DurablePushStore(traceDatabase(db, [], errors), clock) + const parse = vi.spyOn(JSON, 'parse') + let caught: unknown + try { + await owner.claim() + } catch (error) { + caught = error + } + expect(errors).toHaveLength(1) + expect(caught).toBe(errors[0]) + expect(await originalQuery('SELECT * FROM push_delivery_batches')).toEqual([before]) + expect(parse.mock.calls.filter(([value]) => value === String(before.payload_json))).toHaveLength( + 1 + ) + await originalQuery( + db.dialect === 'sqlite' + ? 'DROP TRIGGER deny_lease' + : 'ALTER TABLE push_delivery_batches DROP CONSTRAINT deny_lease' + ) + const fresh = await owner.claim() + expect(fresh?.notification).toEqual(notification(1)) + expect(fresh?.attempts).toBe(1) +}) diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts index b84ebb178934..9456e25743e9 100644 --- a/cloud/apps/push/src/durable-push-store.ts +++ b/cloud/apps/push/src/durable-push-store.ts @@ -153,15 +153,15 @@ export class DurablePushStore { 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?', [lease, now + DELIVERY_LEASE_MS, row.batch_id] ) - return this.delivery(row, lease) + return this.delivery(row, lease, notification) } - private delivery(row: SqlRow, lease: string): QueuedPushDelivery { + private delivery(row: SqlRow, lease: string, notification: PushNotification): QueuedPushDelivery { return { id: String(row.batch_id), registrationId: String(row.registration_id), hostFingerprint: String(row.host_fingerprint), - notification: parsePushDeliveryPayload(String(row.payload_json)), + notification, expiresAt: Number(row.expires_at), lease, attempts: Number(row.attempts) + 1 diff --git a/config/build-plugins/jsonc-parser-esm.ts b/config/build-plugins/jsonc-parser-esm.ts new file mode 100644 index 000000000000..f3403bc9463a --- /dev/null +++ b/config/build-plugins/jsonc-parser-esm.ts @@ -0,0 +1,6 @@ +import { resolve } from 'node:path' + +// UMD relative requires cannot survive a self-contained bundle. +export const JSONC_PARSER_ESM_ALIAS = { + 'jsonc-parser': resolve(import.meta.dirname, '../../node_modules/jsonc-parser/lib/esm/main.js') +} diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 716bc1e4c639..5ea771e293c0 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -19,6 +19,7 @@ type OutputChunk = Rollup.OutputChunk // The CLI loads these paths after electron-vite replaces out/main. export const CLI_MAIN_ENTRY_NAMES = [ 'agent-hooks/managed-agent-hook-controls', + 'gitlab/project-ref-parser', 'orca-profiles/profile-index-store', 'claude-accounts/keychain', ...[ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 7c146a29171f..b0666c42a3d6 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -164,9 +164,10 @@ const rpmElectronRuntimeDependencies = [ ] // Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. -// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with +// Keep in sync with isOsOpenedDocumentName() in src/main/startup/os-opened-documents.ts and with // config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] +const TABULAR_FILE_EXTENSIONS = ['csv', 'tsv'] // Why: the config must load on a host-only install without resolving unused Windows addons. // This is load-time tolerance only; beforePack enforces that the target's natives are installed. @@ -302,6 +303,7 @@ module.exports = { 'out/main/cursor/**', 'out/main/droid/**', 'out/main/gemini/**', + 'out/main/gitlab/project-ref-parser.js', 'out/main/grok/**', 'out/main/hermes/**', 'out/main/orca-profiles/profile-index-store.js', @@ -509,16 +511,25 @@ module.exports = { include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh') }, mac: { - // Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming + // Why rank Alternate: Orca joins Finder's "Open With" list without claiming // LSHandlerRank ownership, so whichever editor the user already prefers stays the default. // Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break. - fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ - ext, - name: 'Markdown Document', - description: 'Markdown Document', - role: 'Editor', - rank: 'Alternate' - })), + fileAssociations: [ + ...MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: 'Markdown Document', + description: 'Markdown Document', + role: 'Editor', + rank: 'Alternate' + })), + ...TABULAR_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: `${ext.toUpperCase()} Document`, + description: `${ext.toUpperCase()} Document`, + role: 'Editor', + rank: 'Alternate' + })) + ], icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', @@ -611,7 +622,7 @@ module.exports = { // override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the // default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to // application/x-genesis-32x-rom, so claiming it here would need a glob override. - mimeTypes: ['text/markdown'], + mimeTypes: ['text/markdown', 'text/csv', 'text/tab-separated-values'], // Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca. // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index a6fb31f9aec9..6d6dd0695922 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -6,7 +6,7 @@ !include "${__FILEDIR__}\orca-process-check.nsh" ; --------------------------------------------------------------------------- -; Markdown "Open with Orca" (issue #10138) +; Markdown and CSV/TSV "Open with Orca" (issues #10138, #23225) ; ; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows: ; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is @@ -21,29 +21,36 @@ ; exactly where the user left it. Never add a `Software\Classes\.` default ; value here. ; -; MARKDOWN_PROGID must stay in sync with the extension list handled by -; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts. +; Keep the extension list in sync with isOsOpenedDocumentName(). ; --------------------------------------------------------------------------- !define MARKDOWN_PROGID "Orca.Markdown" +!define TABULAR_PROGID "Orca.Tabular" -!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT - WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" +!macro ORCA_REGISTER_DOCUMENT_OPEN_WITH EXT PROGID + WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}" WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" "" !macroend -!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT - DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" +!macro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH EXT PROGID + DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}" DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" !macroend +!macro ORCA_REGISTER_DOCUMENT_PROGID PROGID NAME + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}" "" "${NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\DefaultIcon" "" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open\command" "" '"$appExe" "%1"' +!macroend + !macro customInstall - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document" - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0" - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"' - !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md" - !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown" - !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx" + !insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${MARKDOWN_PROGID}" "Markdown Document" + !insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}" ; Why: Explorer caches the association list until told otherwise. System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" !macroend @@ -100,8 +107,11 @@ ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" - !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md" - !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown" - !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx" + DeleteRegKey SHELL_CONTEXT "Software\Classes\${TABULAR_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}" System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" !macroend diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 09f5f913a94e..40e47746747e 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -363,15 +363,12 @@ export function mobileWebAppBuildOptions(routes) { */ export function entryStaticClosure(metafile, entryOutputPath) { const reached = new Set([entryOutputPath]) - const queue = [entryOutputPath] - while (queue.length > 0) { - const current = queue.shift() + for (const current of reached) { for (const imported of metafile.outputs[current]?.imports ?? []) { if (imported.kind !== 'import-statement' || reached.has(imported.path)) { continue } reached.add(imported.path) - queue.push(imported.path) } } return reached diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 20dd1e0b17a3..83332aefa444 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -9,6 +9,7 @@ * gracefully degraded. */ import { build } from 'esbuild' +import { JSONC_PARSER_ESM_ALIAS } from '../build-plugins/jsonc-parser-esm.ts' import { createHash } from 'node:crypto' import { copyFileSync, @@ -60,7 +61,6 @@ const MANAGED_HOOK_RUNTIME_ENTRY = join( 'agent-hooks', 'managed-hook-runtime.ts' ) -const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( ROOT, @@ -102,6 +102,7 @@ const RELAY_VERSION = '0.1.0' async function buildRelayBundles(outDir) { await build({ entryPoints: [RELAY_ENTRY], + alias: JSONC_PARSER_ESM_ALIAS, bundle: true, platform: 'node', target: 'node18', @@ -186,7 +187,7 @@ async function buildRelayBundles(outDir) { outfile: join(outDir, 'managed-hook-runtime.js'), // Why: jsonc-parser's default UMD build keeps relative dynamic requires // that break after bundling; its ESM entry is equivalent and self-contained. - alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY }, + alias: JSONC_PARSER_ESM_ALIAS, sourcemap: false, minify: true, define: { @@ -293,6 +294,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) { mkdirSync(outDir, { recursive: true }) await build({ entryPoints: [wslHookEntry], + alias: JSONC_PARSER_ESM_ALIAS, bundle: true, platform: 'node', target: 'node18', diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index b7c73a669b0d..9b213031aa7d 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -258,6 +258,16 @@ export function collectBaseLineBlocks(root, comparisonBase, files = null) { } export function isMovedCode(highlightedLines, baseBlocks) { + return createMovedCodeMatcher(baseBlocks)(highlightedLines) +} + +export function createMovedCodeMatcher(baseBlocks) { + // Base-revision blocks stay fixed for the gate run; normalize each visited block once. + const normalizedBlocks = new Map() + return (highlightedLines) => matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks) +} + +function matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks) { const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '') if (needle.length === 0) { return false @@ -270,8 +280,12 @@ export function isMovedCode(highlightedLines, baseBlocks) { // and nearly all of it must be present. Genuinely new code shares neither the // anchor nor the ordering, so it stays reported. const MIN_COVERAGE = 0.9 - return baseBlocks.some((rawHaystack) => { - const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '') + return baseBlocks.some((block) => { + let haystack = normalizedBlocks.get(block) + if (!haystack) { + haystack = block.map(normalizeSourceLine).filter((line) => line !== '') + normalizedBlocks.set(block, haystack) + } for (let start = 0; start < haystack.length; start += 1) { if (haystack[start] !== needle[0]) { continue @@ -309,7 +323,8 @@ export function diagnosticTouchesAddedLines( diagnostic, rangesByFile, root = process.cwd(), - baseBlocks = [] + baseBlocks = [], + movedCodeMatcher = isMovedCode ) { const file = normalizedDiagnosticPath(root, diagnostic.filename) const ranges = rangesByFile.get(file) @@ -321,7 +336,7 @@ export function diagnosticTouchesAddedLines( if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) { return false } - return !isMovedCode( + return !movedCodeMatcher( diagnosticHighlightedLines(root, diagnostic.filename, label.span), baseBlocks ) @@ -455,6 +470,7 @@ export function main( } const baseBlocks = collectBaseLineBlocks(root, comparisonBase) + const movedCodeMatcher = createMovedCodeMatcher(baseBlocks) let failures = 0 for (const scan of OXLINT_SCANS) { @@ -463,7 +479,7 @@ export function main( !isSuppressedDiagnostic(diagnostic, root) && !isCastingDirectiveUnusedWarning(diagnostic, root) && !isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scan.label) && - diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) + diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks, movedCodeMatcher) ) for (const diagnostic of diagnostics) { printDiagnostic(diagnostic, root) diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs index f491c1749ded..7a3e035e45a1 100644 --- a/config/scripts/ci-unit-dependency-graph.mjs +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -42,9 +42,14 @@ export function buildUnitDependencyGraph(sources) { if (path === null) { continue } - const resolved = EXTENSIONS.map((extension) => path + extension).find((candidate) => - sources.has(candidate) - ) + let resolved + for (const extension of EXTENSIONS) { + const candidate = path + extension + if (sources.has(candidate)) { + resolved = candidate + break + } + } if (!resolved) { opaque.add(file) continue diff --git a/config/scripts/ci-unit-import-resolution-budget.test.mjs b/config/scripts/ci-unit-import-resolution-budget.test.mjs new file mode 100644 index 000000000000..bf1f04941b55 --- /dev/null +++ b/config/scripts/ci-unit-import-resolution-budget.test.mjs @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' + +describe('unit graph import resolution', () => { + it('avoids allocating an extension-candidate array for every resolved import', () => { + const consumers = Array.from({ length: 1000 }, (_, index) => `src/consumer-${index}.ts`) + const sources = new Map([ + ['src/leaf', 'export const value = 1'], + ...consumers.map((file) => [file, "import './leaf'"]) + ]) + const originalMap = Array.prototype.map + let extensionArrays = 0 + const spy = vi.spyOn(Array.prototype, 'map').mockImplementation(function (...args) { + if (this.length === 10 && this[0] === '' && this[1] === '.ts' && this[9] === '/index.js') { + extensionArrays += 1 + } + return originalMap.apply(this, args) + }) + try { + const graph = buildUnitDependencyGraph(sources) + spy.mockRestore() + expect([...graph.reverse]).toEqual([['src/leaf', new Set(consumers)]]) + expect(graph.opaque).toEqual(new Set()) + expect(extensionArrays).toBe(0) + } finally { + spy.mockRestore() + } + }) + + it('keeps first-match precedence across literal paths, extensions and index files', () => { + const sources = new Map([ + ['src/leaf', ''], + ['src/leaf.ts', ''], + ['src/leaf.tsx', ''], + ['src/component.tsx', ''], + ['src/component.js', ''], + ['src/folder/index.ts', ''], + ['src/folder/index.tsx', ''], + ['src/config.json', '{}'], + ['src/renderer/src/view.tsx', ''], + ['src/use.ts', "import './leaf'; import './component'; import './folder'; import './config'"], + ['src/aliases.ts', "import '@renderer/view'; import '@/view'; import 'external-package'"], + ['src/missing.ts', "import './missing-file'"], + ['src/dynamic.ts', 'import(variablePath)'], + ['config/owner.mjs', "import '../src/leaf'"], + ['tests/owner.ts', "import '../src/leaf'"] + ]) + expect(buildUnitDependencyGraph(sources)).toEqual({ + reverse: new Map([ + ['src/leaf', new Set(['src/use.ts', 'config/owner.mjs', 'tests/owner.ts'])], + ['src/component.tsx', new Set(['src/use.ts'])], + ['src/folder/index.ts', new Set(['src/use.ts'])], + ['src/config.json', new Set(['src/use.ts'])], + ['src/renderer/src/view.tsx', new Set(['src/aliases.ts'])] + ]), + opaque: new Set(['src/missing.ts', 'src/dynamic.ts', 'config/owner.mjs', 'tests/owner.ts']) + }) + }) +}) diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs index 6b580bdb0025..94dcd933f45a 100644 --- a/config/scripts/ci-unit-sequencer.mjs +++ b/config/scripts/ci-unit-sequencer.mjs @@ -16,13 +16,14 @@ export default class TimingSequencer extends BaseSequencer { 'utf8' ) ) + const discovered = new Set(plan.files) if ( plan.version !== 1 || !plan.sourceSha || plan.sourceSha !== process.env.ORCA_SHARD_SOURCE_SHA || JSON.stringify([...plan.files].sort()) !== JSON.stringify(specs.map(key).sort()) || !Array.isArray(plan.executionFiles) || - plan.executionFiles.some((file) => !plan.files.includes(file)) + plan.executionFiles.some((file) => !discovered.has(file)) ) { throw new Error('Selection provenance or discovery differs') } diff --git a/config/scripts/ci-unit-sequencer.test.mjs b/config/scripts/ci-unit-sequencer.test.mjs index 931a3ccd1582..a14ab60b4974 100644 --- a/config/scripts/ci-unit-sequencer.test.mjs +++ b/config/scripts/ci-unit-sequencer.test.mjs @@ -6,39 +6,86 @@ import TimingSequencer from './ci-unit-sequencer.mjs' let root afterEach(() => { + vi.restoreAllMocks() vi.unstubAllEnvs() if (root) { rmSync(root, { recursive: true, force: true }) } }) -it.each(['valid', 'stale', 'missing-file', 'missing-artifact'])( - 'preserves complete shard coverage with %s planning evidence', - async (kind) => { - root = mkdtempSync(join(tmpdir(), 'unit-sequencer-')) - const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts'] - const plan = { - version: 1, - sourceSha: kind === 'stale' ? 'old' : 'current', - files: kind === 'missing-file' ? files.slice(1) : files, - executionFiles: files.slice(0, 2) - } - const planPath = join(root, 'selection.json') - if (kind !== 'missing-artifact') { - writeFileSync(planPath, JSON.stringify(plan)) - } - vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) - vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') - vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) - const assigned = [] - for (const index of [1, 2]) { - const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } }) - const specs = files.map((file) => ({ moduleId: join(root, file) })) - assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId)) - } - expect(assigned.sort()).toEqual( - (kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort() - ) - expect(new Set(assigned).size).toBe(assigned.length) +it.each([ + 'valid', + 'stale', + 'missing-file', + 'missing-artifact', + 'outside-selection', + 'empty-selection' +])('preserves complete shard coverage with %s planning evidence', async (kind) => { + root = mkdtempSync(join(tmpdir(), 'unit-sequencer-')) + const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts'] + const plan = { + version: 1, + sourceSha: kind === 'stale' ? 'old' : 'current', + files: kind === 'missing-file' ? files.slice(1) : files, + executionFiles: + kind === 'outside-selection' + ? ['src/unknown.test.ts'] + : kind === 'empty-selection' + ? [] + : files.slice(0, 2) } -) + const planPath = join(root, 'selection.json') + if (kind !== 'missing-artifact') { + writeFileSync(planPath, JSON.stringify(plan)) + } + vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) + vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') + vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) + const assigned = [] + for (const index of [1, 2]) { + const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } }) + const specs = files.map((file) => ({ moduleId: join(root, file) })) + assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId)) + } + expect(assigned.sort()).toEqual( + (kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort() + ) + expect(new Set(assigned).size).toBe(assigned.length) +}) + +it('validates a large selection without scanning the discovered array for each file', async () => { + root = mkdtempSync(join(tmpdir(), 'unit-sequencer-scale-')) + const files = Array.from({ length: 1600 }, (_, index) => `src/scale-${index}.test.ts`) + const executionFiles = files.slice(800) + const planPath = join(root, 'selection.json') + writeFileSync( + planPath, + JSON.stringify({ version: 1, sourceSha: 'current', files, executionFiles }) + ) + vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) + vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') + vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) + const sequencer = new TimingSequencer({ config: { root, shard: { index: 1, count: 1 } } }) + const specs = files.map((file) => ({ moduleId: join(root, file) })) + const includes = Array.prototype.includes + let discoveredArrayScans = 0 + const scan = vi + .spyOn(Array.prototype, 'includes') + .mockImplementation(function (value, fromIndex) { + if ( + this.length === files.length && + this[0] === files[0] && + typeof value === 'string' && + value.startsWith('src/scale-') + ) { + discoveredArrayScans += 1 + } + return includes.call(this, value, fromIndex) + }) + const selected = await sequencer.shard(specs) + scan.mockRestore() + expect(selected.map((spec) => spec.moduleId).sort()).toEqual( + executionFiles.map((file) => join(root, file)).sort() + ) + expect(discoveredArrayScans).toBe(0) +}) diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs index 58f6f8d88655..654c6db246f3 100644 --- a/config/scripts/electron-builder-markdown-associations.test.mjs +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -8,6 +8,8 @@ const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx'] +const TABULAR_EXTENSIONS = ['csv', 'tsv'] +const DOCUMENT_EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...TABULAR_EXTENSIONS] // The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("") // value of Software\Classes\.. Additive `WriteRegNone ...\OpenWithProgids` must not @@ -23,23 +25,23 @@ const stripNsisCommentLines = (source) => const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8') -describe('electron-builder markdown file associations', () => { +describe('electron-builder document file associations', () => { // Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS // packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value // — silently taking .md from whichever editor owns it, for every existing user on their // next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot // prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must // stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks. - it('never claims the Windows default markdown handler', () => { + it('never claims the Windows default document handler', () => { expect(electronBuilderConfig.fileAssociations).toBeUndefined() expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined() }) - it('joins the macOS Open With list for every markdown extension without owning it', () => { + it('joins the macOS Open With list for every supported extension without owning it', () => { const associations = electronBuilderConfig.mac.fileAssociations // One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob. expect([...associations].map((association) => association.ext).sort()).toEqual( - [...MARKDOWN_EXTENSIONS].sort() + [...DOCUMENT_EXTENSIONS].sort() ) for (const association of associations) { expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' }) @@ -54,6 +56,14 @@ describe('electron-builder markdown file associations', () => { expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined() }) + it('adds CSV and TSV handlers to the Linux desktop entry', () => { + expect(electronBuilderConfig.linux.mimeTypes).toEqual([ + 'text/markdown', + 'text/csv', + 'text/tab-separated-values' + ]) + }) + it('points the single NSIS include at the installer hooks file on disk', () => { const includePath = electronBuilderConfig.nsis.include expect(existsSync(includePath)).toBe(true) @@ -84,7 +94,7 @@ describe('electron-builder markdown file associations', () => { expect(stripped).toMatch(DEFAULT_HANDLER_WRITE) }) - it('registers Windows markdown Open With additively, never as the default', async () => { + it('registers Windows document Open With additively, never as the default', async () => { const hooks = await readInstallerHooks() expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE) @@ -92,11 +102,18 @@ describe('electron-builder markdown file associations', () => { expect(hooks).toMatch( /WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/ ) - expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/) + expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_DOCUMENT_OPEN_WITH\s+EXT\s+PROGID/) for (const ext of MARKDOWN_EXTENSIONS) { - expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) - expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + expect(hooks).toContain(`ORCA_REGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${MARKDOWN_PROGID}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${MARKDOWN_PROGID}"`) + } + for (const ext of TABULAR_EXTENSIONS) { + expect(hooks).toContain(`ORCA_REGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${TABULAR_PROGID}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${TABULAR_PROGID}"`) } + expect(hooks).toContain('!define TABULAR_PROGID "Orca.Tabular"') + expect(hooks).toContain('ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document"') + expect(hooks).toContain('DeleteRegKey SHELL_CONTEXT "Software\\Classes\\${TABULAR_PROGID}"') expect(hooks).toMatch(/!macro\s+customInstall\b/) expect(hooks).toMatch(/!macro\s+customUnInstall\b/) }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index da6d45c8a230..c73445b13b2e 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -99,13 +99,14 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) - it('keeps offline profile-state CLI imports unpacked at stable paths', () => { + it('keeps CLI main imports unpacked at stable paths', () => { const input = electronViteConfig.main?.build?.rollupOptions?.input if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new Error('Expected named main-process inputs') } for (const name of [ + 'gitlab/project-ref-parser', 'orca-profiles/profile-index-store', 'persistence/profile-state/profile-state-access', 'persistence/profile-state/profile-state-active-location', @@ -121,6 +122,7 @@ describe('Electron Vite output contract', () => { ]) { expect(input).toHaveProperty(name) } + expect(electronBuilderConfig.asarUnpack).toContain('out/main/gitlab/project-ref-parser.js') expect(electronBuilderConfig.asarUnpack).toContain('out/main/persistence/profile-state/**') expect(electronBuilderConfig.asarUnpack).toContain( 'out/main/orca-profiles/profile-index-store.js' diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 3d701b23944e..a91b4f528c4f 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -9,10 +9,12 @@ import { ensureWindowsProcessTreeCommandLinePatch, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs, stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' const require = createRequire(import.meta.url) const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs') @@ -404,6 +406,7 @@ function rebuildNodeRuntimeModules(moduleNames) { console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) // pnpm exec inside an installed addon cannot discover the root build tool. runNodeGyp( + moduleName, nodeGypRebuildInvocation( process.arch, moduleDir, @@ -416,18 +419,18 @@ function rebuildNodeRuntimeModules(moduleNames) { } } -function runNodeGyp({ args, cwd }) { +function runNodeGyp(moduleName, { args, cwd }) { const env = process.platform === 'linux' ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() } - : process.env + : disableMsbuildFileTrackingOnWindows({ ...process.env }) const result = runProcessSync({ program: process.execPath, args, cwd, env, stdio: 'inherit', - timeoutMs: 300_000 + timeoutMs: nodeGypRebuildTimeoutMs(moduleName) }) if (result.code !== 0) { console.error( diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 4d6e022b1855..68a10b5bb206 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -20,7 +20,10 @@ import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution. import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' -import { nodeGypRebuildInvocation } from './windows-process-tree-gyp-rebuild.mjs' +import { + nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs +} from './windows-process-tree-gyp-rebuild.mjs' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) // The import walk sees `from './x.mjs'` only, so the createRequire'd CJS @@ -60,6 +63,8 @@ describe('ensure-native-runtime', () => { expect(result.stderr).toContain('node-gyp stderr complete\n') const log = readFileSync(logPath, 'utf8') expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`) + expect(log).toContain(`node-gyp timeout=${nodeGypRebuildTimeoutMs('node-pty')}\n`) + expect(log).toContain(`trackFileAccess=${process.platform === 'win32' ? 'false' : ''}\n`) expect(log).toContain(join('node_modules', 'node-pty')) if (process.platform === 'linux') { expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m) @@ -73,9 +78,14 @@ describe('ensure-native-runtime', () => { } }) - it.skipIf(process.platform !== 'win32')( - 'rebuilds other failed Windows addons with patched node-pty', - () => { + it.skipIf(process.platform !== 'win32').each([ + { trackingEnv: {}, tracking: 'false' }, + { trackingEnv: { TrackFileAccess: 'true' }, tracking: 'true' }, + { trackingEnv: { trackfileaccess: 'true' }, tracking: 'true' }, + { trackingEnv: { tRaCkFiLeAcCeSs: 'false' }, tracking: 'false' } + ])( + 'rebuilds other failed Windows addons with patched node-pty and tracking=$tracking', + ({ trackingEnv, tracking }) => { const projectDir = mkTempProject() try { @@ -90,6 +100,7 @@ describe('ensure-native-runtime', () => { cwd: projectDir, encoding: 'utf8', env: envForNativeFixture(projectDir, { + ...trackingEnv, ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -100,6 +111,9 @@ describe('ensure-native-runtime', () => { expect( log.split('\n').filter((line) => line === `node-gyp rebuild --arch=${process.arch}`) ).toHaveLength(2) + expect( + log.split('\n').filter((line) => line === `trackFileAccess=${tracking}`) + ).toHaveLength(2) expect(log).toContain(join('node_modules', 'node-pty')) expect(log).toContain(join('node_modules', '@orca', 'windows-registry')) } finally { @@ -265,7 +279,14 @@ function mkTempProject() { copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) writeFileSync( join(projectDir, 'config', 'scripts', 'script-child-process.mjs'), - `export { describeProcessFailure, runProcessSync } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}\n` + `import { appendFileSync } from 'node:fs' +import { describeProcessFailure, runProcessSync as run } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)} +export { describeProcessFailure } +export function runProcessSync(options) { + appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp timeout=\${options.timeoutMs}\\n\`) + return run(options) +} +` ) for (const name of REQUIRED_CJS_SIBLINGS) { copyFileSync( @@ -277,8 +298,12 @@ function mkTempProject() { } function envForNativeFixture(projectDir, extraEnv) { + // An inherited tracking preference would mask the Windows default under test. + const inherited = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'trackfileaccess') + ) return { - ...process.env, + ...inherited, ...extraEnv, npm_config_node_gyp: join(projectDir, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js') } @@ -323,7 +348,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker }) if (process.platform === 'win32') { const buildDir = join(nodePtyDir, 'build', 'Release') - mkdirSync(buildDir, { recursive: true }) + writePatchedNodePtyBuildArtifacts(projectDir) writeFileSync(join(buildDir, 'conpty.node'), Buffer.from('msys-2.0.dll', 'utf16le')) } } @@ -419,6 +444,7 @@ const { appendFileSync, writeFileSync, writeSync } = require('node:fs') appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp \${process.argv.slice(2).join(' ')}\\n\`) appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`) appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`) +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`trackFileAccess=\${process.env.TrackFileAccess ?? ''}\\n\`) if (process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES) { const output = Buffer.alloc(Number(process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES), 'x') for (let offset = 0; offset < output.length;) { diff --git a/config/scripts/generate-monaco-associations.mjs b/config/scripts/generate-monaco-associations.mjs index b5c2e99c00a6..ea81be3fa471 100644 --- a/config/scripts/generate-monaco-associations.mjs +++ b/config/scripts/generate-monaco-associations.mjs @@ -10,6 +10,12 @@ export const associationsPath = fileURLToPath( new URL('../../src/renderer/src/lib/monaco-language-associations.json', import.meta.url) ) +// Monaco omits common Ruby task, template and configuration files. +const rubyAssociations = { + extensions: ['.rake', '.ru', '.jbuilder', '.thor'], + filenames: ['Guardfile', 'Capfile', 'Podfile', 'Brewfile', 'Vagrantfile'] +} + // Read registration metadata without importing Monaco or executing its grammar loaders. export function readMonacoAssociations() { const entry = ts.createSourceFile( @@ -62,6 +68,12 @@ export function readMonacoAssociations() { if (!metadata.id) { throw new Error(`Missing language id in ${file}`) } + if (metadata.id === 'ruby') { + metadata.extensions = [ + ...new Set([...metadata.extensions, ...rubyAssociations.extensions]) + ] + metadata.filenames = [...new Set([...metadata.filenames, ...rubyAssociations.filenames])] + } registrations.push(metadata) } ts.forEachChild(node, visit) diff --git a/config/scripts/generate-monaco-associations.test.mjs b/config/scripts/generate-monaco-associations.test.mjs index 0e7563cd49ad..1076ea78c8a1 100644 --- a/config/scripts/generate-monaco-associations.test.mjs +++ b/config/scripts/generate-monaco-associations.test.mjs @@ -3,10 +3,36 @@ import { describe, expect, it } from 'vitest' import { associationsPath, readMonacoAssociations } from './generate-monaco-associations.mjs' describe('Monaco filename associations', () => { - it('matches every registration shipped by the installed editor entry point', () => { + it('matches the installed editor registrations and curated Orca associations', () => { expect( JSON.parse(readFileSync(associationsPath, 'utf8')), - 'Run node config/scripts/generate-monaco-associations.mjs after upgrading Monaco' + 'Run node config/scripts/generate-monaco-associations.mjs after changing associations or Monaco' ).toEqual(readMonacoAssociations()) }) + + it('keeps built-in Ruby aliases alongside the curated Ruby associations', () => { + expect(readMonacoAssociations().find((language) => language.id === 'ruby')).toEqual({ + id: 'ruby', + extensions: expect.arrayContaining([ + '.rb', + '.rbx', + '.rjs', + '.gemspec', + '.pp', + '.rake', + '.ru', + '.jbuilder', + '.thor' + ]), + filenames: expect.arrayContaining([ + 'rakefile', + 'Gemfile', + 'Guardfile', + 'Capfile', + 'Podfile', + 'Brewfile', + 'Vagrantfile' + ]) + }) + }) }) diff --git a/config/scripts/hang-watchdog-process-metrics.mjs b/config/scripts/hang-watchdog-process-metrics.mjs index 7e5ee4de53f5..ddee2806e23f 100644 --- a/config/scripts/hang-watchdog-process-metrics.mjs +++ b/config/scripts/hang-watchdog-process-metrics.mjs @@ -90,9 +90,10 @@ export async function sampleProductionPerformance(boundary, options) { heartbeatCount += 1 boundary.sendHeartbeat() }, options.heartbeatIntervalMs) - const cpuBefore = combinedCpuTimeMs(boundary.pids) - loopDelay.enable() + let cpuBefore try { + cpuBefore = combinedCpuTimeMs(boundary.pids) + loopDelay.enable() await options.sleep(options.sampleMs) } finally { loopDelay.disable() diff --git a/config/scripts/hang-watchdog-process-metrics.test.mjs b/config/scripts/hang-watchdog-process-metrics.test.mjs index 2d54e691d007..54943322a01b 100644 --- a/config/scripts/hang-watchdog-process-metrics.test.mjs +++ b/config/scripts/hang-watchdog-process-metrics.test.mjs @@ -1,9 +1,15 @@ -import { describe, expect, it } from 'vitest' +import childProcess from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' +import perfHooks from 'node:perf_hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parsePhysicalFootprintBytes, - parseProcessCpuTimeMs + parseProcessCpuTimeMs, + sampleProductionPerformance } from './hang-watchdog-process-metrics.mjs' +const observations = { events: [], readCpu: null, histogram: null } + describe('hang watchdog process metrics', () => { it('uses the de-duplicated summary for multiple processes', () => { const output = ` @@ -43,3 +49,203 @@ Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) expect(parseProcessCpuTimeMs('-1:00')).toBeNull() }) }) + +function cpuObservation(pid) { + return ['cpu', 'ps', ['-o', 'time=', '-p', String(pid)], { encoding: 'utf8' }] +} + +describe('production watchdog sample lifetime', () => { + beforeEach(() => { + vi.useFakeTimers() + observations.events = [] + let enabled = false + observations.histogram = { + enable() { + observations.events.push(['enable']) + const changed = !enabled + enabled = true + return changed + }, + disable() { + observations.events.push(['disable']) + const changed = enabled + enabled = false + return changed + }, + percentile: (value) => { + observations.events.push(['percentile', value]) + return value === 95 ? 1_900_000 : 3_100_000 + }, + max: 5_000_000 + } + vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args, options) => { + observations.events.push(['cpu', command, args, options]) + return observations.readCpu() + }) + vi.spyOn(perfHooks, 'monitorEventLoopDelay').mockImplementation((options) => { + observations.events.push(['monitor', options]) + return observations.histogram + }) + syncBuiltinESMExports() + }) + afterEach(() => { + vi.clearAllTimers() + vi.restoreAllMocks() + syncBuiltinESMExports() + vi.useRealTimers() + }) + + it.each([1, 2])('clears the heartbeat when initial CPU observation %s throws', async (failAt) => { + const error = new Error('PID observation failed') + let reads = 0 + observations.readCpu = () => { + if (++reads === failAt) { + throw error + } + return '0:01.20' + } + const sendHeartbeat = vi.fn() + const sleep = vi.fn() + await expect( + sampleProductionPerformance( + { pids: [101, 102], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 30_000, sleep } + ) + ).rejects.toBe(error) + expect(reads).toBe(failAt) + expect(sleep).not.toHaveBeenCalled() + expect(observations.events.filter(([kind]) => kind !== 'disable')).toEqual([ + ['monitor', { resolution: 10 }], + ...[101, 102].slice(0, failAt).map(cpuObservation) + ]) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(4_000) + expect(sendHeartbeat).not.toHaveBeenCalled() + }) + + it('preserves an invalid CPU diagnostic while releasing the heartbeat', async () => { + observations.readCpu = () => 'invalid CPU time' + const sendHeartbeat = vi.fn() + const sleep = vi.fn() + await expect( + sampleProductionPerformance( + { pids: [101], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 30_000, sleep } + ) + ).rejects.toThrow('Could not read CPU time for PID 101') + expect(sleep).not.toHaveBeenCalled() + expect(observations.events.filter(([kind]) => kind !== 'disable')).toEqual([ + ['monitor', { resolution: 10 }], + cpuObservation(101) + ]) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(4_000) + expect(sendHeartbeat).not.toHaveBeenCalled() + }) + + it('repeated failed owners leave no timers or later sends', async () => { + const error = new Error('sample CPU failure') + observations.readCpu = () => { + throw error + } + const sendHeartbeat = vi.fn() + const sleep = vi.fn() + for (let index = 0; index < 64; index++) { + await expect( + sampleProductionPerformance( + { pids: [101], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 30_000, sleep } + ) + ).rejects.toBe(error) + } + expect(sleep).not.toHaveBeenCalled() + expect(observations.events.filter(([kind]) => kind !== 'disable')).toEqual( + Array.from({ length: 64 }, () => [ + ['monitor', { resolution: 10 }], + cpuObservation(101) + ]).flat() + ) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(4_000) + expect(sendHeartbeat).not.toHaveBeenCalled() + }) + + async function runSample({ values, sleepError } = {}) { + const cpuValues = values ?? ['0:01.20', '0:02.30', '0:01.25', '0:02.35'] + let index = 0 + observations.readCpu = () => { + const value = cpuValues[index++] + if (value instanceof Error) { + throw value + } + return value + } + const sendHeartbeat = vi.fn(() => observations.events.push(['heartbeat'])) + const sleep = async (ms) => { + observations.events.push(['sleep', ms]) + await vi.advanceTimersByTimeAsync(ms) + if (sleepError) { + throw sleepError + } + } + return sampleProductionPerformance( + { pids: [101, 102], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 6_000, sleep } + ) + } + + const completedSampleEvents = [ + ['monitor', { resolution: 10 }], + cpuObservation(101), + cpuObservation(102), + ['enable'], + ['sleep', 6_000], + ['heartbeat'], + ['heartbeat'], + ['heartbeat'], + ['disable'] + ] + + it('keeps complete live results, observation order and heartbeat pacing', async () => { + expect(await runSample()).toEqual({ + cpuMs: 100, + heartbeatCount: 3, + eventLoopDelayP95Ms: 1.9, + eventLoopDelayP99Ms: 3.1, + eventLoopDelayMaxMs: 5 + }) + expect(observations.events).toEqual([ + ...completedSampleEvents, + cpuObservation(101), + cpuObservation(102), + ['percentile', 95], + ['percentile', 99] + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a sample sleep rejection and its existing cleanup', async () => { + const error = new Error('sample sleep rejected') + await expect(runSample({ sleepError: error })).rejects.toBe(error) + expect(observations.events).toEqual(completedSampleEvents) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a final CPU observation failure after cleanup', async () => { + const error = new Error('final CPU read failed') + await expect(runSample({ values: ['0:01.20', '0:02.30', error] })).rejects.toBe(error) + expect(observations.events).toEqual([...completedSampleEvents, cpuObservation(101)]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps the zero floor when the CPU total decreases', async () => { + expect(await runSample({ values: ['0:02.20', '0:03.30', '0:01.25', '0:02.35'] })).toEqual({ + cpuMs: 0, + heartbeatCount: 3, + eventLoopDelayP95Ms: 1.9, + eventLoopDelayP99Ms: 3.1, + eventLoopDelayMaxMs: 5 + }) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index 75f50d15ff62..08b331918919 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -52,6 +52,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Goose', 'Grok', 'Hermes', + 'Jcode', 'Jira', 'Kilocode', 'Kimi', @@ -79,6 +80,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'markdown', 'gh', 'idle', + 'jcode', 'anthropic', 'Discord', 'WSL', diff --git a/config/scripts/mobile-entry-static-closure-work-budget.test.mjs b/config/scripts/mobile-entry-static-closure-work-budget.test.mjs new file mode 100644 index 000000000000..4064afe4068c --- /dev/null +++ b/config/scripts/mobile-entry-static-closure-work-budget.test.mjs @@ -0,0 +1,120 @@ +import { expect, it } from 'vitest' +import { entryStaticClosure } from './build-mobile-web-app-bundle.mjs' + +it.each([1, 12, 128, 1000])( + 'keeps the complete %s-output closure without shifting a second queue', + (count) => { + const paths = Array.from({ length: count }, (_value, index) => `dist/chunk-${index}.js`) + const metafile = { + outputs: Object.fromEntries( + paths.map((path, index) => [ + path, + { + bytes: index + 1, + imports: + index === 0 + ? paths.slice(1).map((child) => ({ kind: 'import-statement', path: child })) + : [] + } + ]) + ) + } + const before = structuredClone(metafile) + const originalShift = Array.prototype.shift + let reached + let shifts = 0 + Array.prototype.shift = function () { + shifts++ + return originalShift.call(this) + } + try { + reached = entryStaticClosure(metafile, paths[0]) + } finally { + Array.prototype.shift = originalShift + } + expect([...reached]).toEqual(paths) + expect(metafile).toEqual(before) + expect([...reached].reduce((total, path) => total + metafile.outputs[path].bytes, 0)).toBe( + (count * (count + 1)) / 2 + ) + const fresh = entryStaticClosure(metafile, paths[0]) + expect(fresh).not.toBe(reached) + reached.add('mutated-result') + expect([...fresh]).toEqual(paths) + expect(shifts).toBe(0) + } +) + +it('keeps breadth-first order, duplicates, cycles, missing chunks and dynamic boundaries', () => { + const metafile = { + outputs: { + entry: { + imports: [ + { kind: 'import-statement', path: 'one' }, + { kind: 'dynamic-import', path: 'deferred' }, + { kind: 'import-statement', path: 'two' }, + { kind: 'import-statement', path: 'one' } + ] + }, + one: { imports: [{ kind: 'import-statement', path: 'three' }] }, + two: { + imports: [ + { kind: 'import-statement', path: 'entry' }, + { kind: 'import-statement', path: 'three' }, + { kind: 'import-statement', path: 'missing' } + ] + }, + three: { imports: [] }, + deferred: { imports: [{ kind: 'import-statement', path: 'deferred-child' }] } + } + } + expect([...entryStaticClosure(metafile, 'entry')]).toEqual([ + 'entry', + 'one', + 'two', + 'three', + 'missing' + ]) + metafile.outputs.one.imports.push({ kind: 'import-statement', path: 'fresh-😀' }) + expect([...entryStaticClosure(metafile, 'entry')]).toEqual([ + 'entry', + 'one', + 'two', + 'three', + 'fresh-😀', + 'missing' + ]) + expect([...entryStaticClosure({ outputs: {} }, 'unknown')]).toEqual(['unknown']) +}) + +it('keeps a later output error after earlier discoveries in the same order', () => { + const error = new Error('later-output') + const reads = [] + const outputs = { + get entry() { + reads.push('entry') + return { + imports: [ + { kind: 'import-statement', path: 'one' }, + { kind: 'import-statement', path: 'two' } + ] + } + }, + get one() { + reads.push('one') + return { imports: [{ kind: 'import-statement', path: 'three' }] } + }, + get two() { + reads.push('two') + throw error + } + } + let caught + try { + entryStaticClosure({ outputs }, 'entry') + } catch (failure) { + caught = failure + } + expect(caught).toBe(error) + expect(reads).toEqual(['entry', 'one', 'two']) +}) diff --git a/config/scripts/moved-code-normalization-budget.test.mjs b/config/scripts/moved-code-normalization-budget.test.mjs new file mode 100644 index 000000000000..cd2e729608c4 --- /dev/null +++ b/config/scripts/moved-code-normalization-budget.test.mjs @@ -0,0 +1,164 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as quality from './check-changed-code-quality.mjs' + +const processCalls = vi.hoisted(() => ({ execFileSync: vi.fn(), spawnSync: vi.fn() })) +// oxlint-disable-next-line anti-slop/no-module-mocking -- Test-only external process boundary; actual main executes. +vi.mock('node:child_process', () => processCalls) +// oxlint-disable-next-line anti-slop/no-module-mocking -- Resolve only the external executable used by this fixture. +vi.mock('./oxlint-cli-invocation.mjs', () => ({ + resolveOxlintInvocation: () => ({ command: 'fixture-oxlint', prefixArgs: [] }) +})) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +const createMatcher = (blocks) => + quality.createMovedCodeMatcher?.(blocks) ?? ((lines) => quality.isMovedCode(lines, blocks)) + +describe('moved-code base normalization budget', () => { + it('normalizes fixed base lines once across repeated changed-line diagnostics', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-moved-code-budget-')) + const file = join(root, 'fixture.mjs') + const source = 'brandNewCall()\n' + writeFileSync(file, source) + const blocks = [Array.from({ length: 5000 }, (_, index) => ` base_line_${index}() `)] + const matcher = createMatcher(blocks) + const ranges = new Map([['fixture.mjs', [{ start: 1, end: 1 }]]]) + const diagnostic = { + filename: file, + labels: [{ span: { line: 1, offset: 0, length: source.length - 1 } }] + } + const original = String.prototype.replace + let normalizedBaseLines = 0 + const spy = vi.spyOn(String.prototype, 'replace').mockImplementation(function (...args) { + if (String(this).startsWith(' base_line_')) { + normalizedBaseLines += 1 + } + return original.apply(this, args) + }) + try { + const results = Array.from({ length: 100 }, () => + quality.diagnosticTouchesAddedLines(diagnostic, ranges, root, blocks, matcher) + ) + spy.mockRestore() + expect(results).toEqual(Array(100).fill(true)) + expect(normalizedBaseLines).toBe(5000) + } finally { + spy.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the existing exemption decisions across mixed repeated highlights', () => { + const body = Array.from({ length: 20 }, (_, index) => `line${index}()`) + const blocks = [[' a() ', '', '\tb()'], body, ['first()', 'last()']] + const matcher = createMatcher(blocks) + const observations = [ + { lines: ['a()', ' ', 'b()'], moved: true }, + { lines: ['', ' '], moved: false }, + { lines: ['brandNewCall()', 'a()'], moved: false }, + { lines: ['first()', 'brandNewCall()'], moved: false }, + { lines: [...body.slice(0, 19), 'newDep,', body[19]], moved: true }, + { + lines: ['line0()', ...Array.from({ length: 18 }, (_, index) => `fresh${index}()`)], + moved: false + }, + { lines: ['b()', 'a()'], moved: false } + ] + for (let repeat = 0; repeat < 10; repeat += 1) { + for (const { lines, moved } of observations) { + expect(matcher(lines)).toBe(moved) + expect(quality.isMovedCode(lines, blocks)).toBe(moved) + } + } + }) + + it('does not normalize unvisited blocks and recomputes for the next invocation', () => { + const unused = [' base_line_unused() '] + const blocks = [['first()'], unused] + const matcher = createMatcher(blocks) + const original = String.prototype.replace + let unusedReads = 0 + const spy = vi.spyOn(String.prototype, 'replace').mockImplementation(function (...args) { + if (String(this).startsWith(' base_line_')) { + unusedReads += 1 + } + return original.apply(this, args) + }) + try { + expect(matcher([])).toBe(false) + expect(matcher(['first()'])).toBe(true) + expect(unusedReads).toBe(0) + } finally { + spy.mockRestore() + } + blocks[0][0] = 'changed()' + const nextMatcher = createMatcher(blocks) + expect(nextMatcher(['changed()'])).toBe(true) + expect(nextMatcher(['first()'])).toBe(false) + expect(quality.isMovedCode(['changed()'], blocks)).toBe(true) + }) + + it('shares the matcher across diagnostics and scans in the actual main entrypoint', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-moved-code-main-')) + const file = join(root, 'fixture.mjs') + writeFileSync(file, 'brandNewCall()\n') + vi.stubEnv('GITHUB_EVENT_NAME', '') + processCalls.execFileSync.mockImplementation((_command, args) => { + if (args[0] === 'rev-list') { + return 'head parent\n' + } + if (args[0] === 'merge-base') { + return 'baseline\n' + } + if (args[0] === 'ls-files') { + return '' + } + if (args.includes('--name-only')) { + return 'fixture.mjs\0' + } + if (args.includes('--unified=0')) { + return '@@ -0,0 +1 @@\n+brandNewCall()\n' + } + throw new Error(`Unexpected Git arguments: ${args.join(' ')}`) + }) + const baseline = Array.from({ length: 1000 }, (_, index) => `base_line_${index}()`) + const diagnostics = Array.from({ length: 10 }, () => ({ + filename: file, + message: 'New code finding', + labels: [{ span: { line: 1, offset: 0, length: 14 } }] + })) + processCalls.spawnSync.mockImplementation((command, args) => { + if (command === 'git') { + return { status: 0, stdout: args[0] === 'show' ? baseline.join('\n') : '' } + } + return { status: 1, stdout: JSON.stringify({ diagnostics }), stderr: '' } + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const replace = String.prototype.replace + let normalizedBaseLines = 0 + vi.spyOn(String.prototype, 'replace').mockImplementation(function (...args) { + if (String(this).startsWith('base_line_')) { + normalizedBaseLines += 1 + } + return replace.apply(this, args) + }) + try { + expect(quality.main(root, 'baseline')).toBe(1) + const scans = processCalls.spawnSync.mock.calls.filter(([command]) => command !== 'git') + expect(scans).toHaveLength(quality.OXLINT_SCANS.length) + expect(error.mock.calls.filter(([message]) => message.startsWith('::error '))).toHaveLength( + diagnostics.length * quality.OXLINT_SCANS.length + ) + expect(normalizedBaseLines).toBe(2000) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/msbuild-file-tracking.mjs b/config/scripts/msbuild-file-tracking.mjs new file mode 100644 index 000000000000..0a2fac8b8043 --- /dev/null +++ b/config/scripts/msbuild-file-tracking.mjs @@ -0,0 +1,14 @@ +// FileTracker's long-path-unsafe .tlog files serve incremental builds; these rebuilds are forced. +export function disableMsbuildFileTrackingOnWindows( + env = process.env, + platform = process.platform +) { + // Windows environment keys are case-insensitive, including caller overrides in copied objects. + if ( + platform === 'win32' && + !Object.keys(env).some((key) => key.toLowerCase() === 'trackfileaccess') + ) { + env.TrackFileAccess = 'false' + } + return env +} diff --git a/config/scripts/msbuild-file-tracking.test.mjs b/config/scripts/msbuild-file-tracking.test.mjs new file mode 100644 index 000000000000..fdde02db25c4 --- /dev/null +++ b/config/scripts/msbuild-file-tracking.test.mjs @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' + +describe('disableMsbuildFileTrackingOnWindows', () => { + it('turns tracking off on Windows when the caller left it unset', () => { + expect(disableMsbuildFileTrackingOnWindows({ PATH: 'x' }, 'win32')).toEqual({ + PATH: 'x', + TrackFileAccess: 'false' + }) + }) + + it.each(['TrackFileAccess', 'trackfileaccess', 'TRACKFILEACCESS', 'tRaCkFiLeAcCeSs'])( + 'preserves explicit %s values without adding a duplicate key', + (key) => { + for (const value of ['true', 'false', '']) { + const env = { [key]: value } + expect(disableMsbuildFileTrackingOnWindows(env, 'win32')).toBe(env) + expect(env).toEqual({ [key]: value }) + } + } + ) + + it.each(['linux', 'darwin'])('leaves %s hosts alone', (platform) => { + const env = { PATH: 'x' } + expect(disableMsbuildFileTrackingOnWindows(env, platform)).toBe(env) + expect(env).toEqual({ PATH: 'x' }) + }) +}) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 2e8b77fd97cc..4c1268cd7a23 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -220,7 +220,7 @@ describe('PR E2E gate contract', () => { const installStep = e2eWorkflow.jobs[jobName].steps.find((step) => step.name.startsWith('Install native build') ) - expect(linuxInstallPackageList(installStep, jobName), jobName).toMatch(/\bzsh\b/) + expect(linuxInstallPackageList(installStep, jobName), jobName).toMatch(/(^|\s)zsh(\s|$)/) } }) @@ -307,10 +307,10 @@ describe('PR E2E gate contract', () => { // Why: this lane can now pay a Docker image build plus serial SSH specs. expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(45) - const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) => + const install = e2eWorkflow.jobs['changed-e2e'].steps.find((step) => step.name.startsWith('Install native build') ) - expect(linuxInstallPackageList(changedInstall, 'changed-e2e')).toMatch(/\bopenssh-client\b/) + expect(linuxInstallPackageList(install, 'changed-e2e')).toMatch(/(^|\s)openssh-client(\s|$)/) }) it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => { diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 1d49c34cc6b9..91156e59bf0b 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -1,4 +1,5 @@ import { existsSync, globSync, readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' import { UNIT_EXCLUDE } from './ci-unit-files.mjs' @@ -515,6 +516,21 @@ describe('PR workflow parallelism', () => { const evidence = workflow.jobs.unit_selection_evidence expect(evidence.uses).toBe('./.github/workflows/unit-selection-evidence.yml') expect(evidence.needs).toEqual(['test']) + for (const [result, cancelled, expected] of [ + ['success', false, true], + ['failure', false, true], + ['skipped', false, false], + ['cancelled', false, false], + ['success', true, false], + ['failure', true, false] + ]) { + expect( + runInNewContext(evidence.if.slice(3, -2), { + cancelled: () => cancelled, + needs: { test: { result } } + }) + ).toBe(expected) + } expect(workflow.jobs.verify.needs).not.toContain('unit_selection_evidence') expect(unitTestWorkflow.jobs.selection_evidence).toBeUndefined() const evidenceWorkflow = parse( diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index a9ec246dd95b..d1153c5f20fd 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -22,6 +22,51 @@ import { } from './rebuild-native-deps-test-fixtures.mjs' describe('rebuild-native-deps patched node-pty rebuild', () => { + it.skipIf(process.platform !== 'win32')( + 'passes the Windows tracking default and explicit overrides to forced Electron rebuilds', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir) + writeFakeWindowsProcessTree(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + + const env = { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + } + for (const override of [ + {}, + { TrackFileAccess: 'true' }, + { trackfileaccess: 'true' }, + { tRaCkFiLeAcCeSs: 'false' } + ]) { + const result = runRebuildScript(projectDir, { ...env, ...override }) + expect(result.status, result.stderr).toBe(0) + } + + const calls = readFileSync(rebuildLogPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(calls.map((call) => call.trackFileAccess)).toEqual([ + 'false', + 'true', + 'true', + 'false' + ]) + expect(calls.every((call) => call.force)).toBe(true) + } finally { + removeTreeSync(projectDir) + } + } + ) + it.skipIf(process.platform !== 'win32')( 'repairs a missing ConPTY runtime before probing without recompiling node-pty', () => { diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 333c8f939ef5..83223b630425 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -110,7 +110,7 @@ export function writeWindowsProcessTreePatchFile(projectDir) { export function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) - copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs')) + copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts')) copyScriptWithLocalModules(sourceNodePtyJobOwnershipPath, join(projectDir, 'config', 'scripts')) copyFileSync( @@ -134,7 +134,8 @@ export function runRebuildScript(projectDir, extraEnv = {}, args = []) { for (const key of Object.keys(env)) { if ( key.toLowerCase() === 'orca_strict_electron_install' || - key.toLowerCase() === 'npm_lifecycle_event' + key.toLowerCase() === 'npm_lifecycle_event' || + key.toLowerCase() === 'trackfileaccess' ) { delete env[key] } @@ -286,6 +287,7 @@ export async function rebuild(options) {${emitAddon} electronVersion: options.electronVersion, force: options.force, ignoreModules: options.ignoreModules, + trackFileAccess: process.env.TrackFileAccess ?? null, onlyModules: options.onlyModules, platform: options.platform }) + '\\n' diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 68de5d49e218..e565eeca97e0 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -26,6 +26,7 @@ import { stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' +import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' import { copyFileSync, existsSync, @@ -162,6 +163,7 @@ try { console.warn('[rebuild] Repaired the un-applied windows-process-tree command-line patch.') } } + disableMsbuildFileTrackingOnWindows() await rebuild({ buildPath: projectDir, electronVersion, diff --git a/config/scripts/terminal-perf-dependency-preparation.test.mjs b/config/scripts/terminal-perf-dependency-preparation.test.mjs new file mode 100644 index 000000000000..3f4c5955f7a2 --- /dev/null +++ b/config/scripts/terminal-perf-dependency-preparation.test.mjs @@ -0,0 +1,116 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { parse } from 'yaml' +import { expect, it } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' + +const workflow = parse(readFileSync('.github/workflows/terminal-perf.yml', 'utf8')) +const steps = workflow.jobs['terminal-perf'].steps +const selector = steps.find((step) => step.id === 'install-mode') +const script = selector.run.trim().match(/^node <<'NODE'\n([\s\S]*)\nNODE$/)[1] +const supportedAction = readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') +const supportedManifest = { + engines: { node: '24' }, + packageManager: 'pnpm@12.8.1', + scripts: { postinstall: 'node config/scripts/rebuild-native-deps.mjs' } +} + +function select(options = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-terminal-preparation-')) + const output = join(directory, 'output') + try { + writeFileSync( + join(directory, 'package.json'), + JSON.stringify(options.manifest ?? supportedManifest) + ) + for (const [file, content] of [ + ['.github/actions/install-node-dependencies/action.yml', options.action ?? supportedAction], + ['.github/actions/prepare-native-runtime/action.yml', 'runs: {}'], + ['config/scripts/ensure-native-runtime.mjs', ''] + ]) { + if (options.missing === file) { + continue + } + const path = join(directory, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + } + const result = runProcessSync({ + program: process.execPath, + args: ['-e', script], + cwd: directory, + env: { + ...process.env, + GITHUB_OUTPUT: output, + RUNNER_KIND: options.kind ?? 'github-hosted', + JOB_CONTAINER: options.container ?? '', + RUNNER_OS: options.os ?? 'Linux', + RUNNER_ARCH: options.arch ?? 'X64' + } + }) + expect(result.code, result.stderr || result.stdout).toBe(0) + return readFileSync(output, 'utf8').trim() + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +it('selects the measured current root profile with the actual installer metadata', () => { + expect(select()).toBe('shared=true') + expect( + select({ manifest: { ...supportedManifest, packageManager: 'pnpm@12.8.1+sha512.fixture' } }) + ).toBe('shared=true') +}) + +it.each([ + ['historical Node', { manifest: { ...supportedManifest, engines: { node: '22' } } }], + ['historical pnpm', { manifest: { ...supportedManifest, packageManager: 'pnpm@10.0.0' } }], + ['unmeasured pnpm', { manifest: { ...supportedManifest, packageManager: 'pnpm@12.8.10' } }], + ['missing toolchain', { manifest: {} }], + [ + 'extra lifecycle work', + { manifest: { ...supportedManifest, scripts: { postinstall: 'generate' } } } + ], + ['self-hosted runner', { kind: 'self-hosted' }], + ['job container', { container: 'container-id' }], + ['another OS', { os: 'Windows' }], + ['another architecture', { arch: 'ARM64' }], + ['missing installer', { missing: '.github/actions/install-node-dependencies/action.yml' }], + ['missing native action', { missing: '.github/actions/prepare-native-runtime/action.yml' }], + ['missing runtime script', { missing: 'config/scripts/ensure-native-runtime.mjs' }], + ['old installer interface', { action: 'inputs:\n native-runtime: {}\nruns: {}\n' }], + [ + 'output-only names', + { action: 'outputs:\n native-runtime: {}\n cache-pnpm-store-lookup-only: {}\n' } + ] +])('retains the original install for %s', (_name, options) => { + expect(select(options)).toBe('shared=false') +}) + +it.each(['true', 'false', ''])('routes mode %s to one complete preparation path', (shared) => { + const enabled = (step) => + runInNewContext(step.if.replaceAll('steps.install-mode.outputs.shared', 'shared'), { shared }) + const current = steps.find((step) => step.name === 'Prepare current dependencies') + const legacy = steps.filter((step) => + [ + 'Setup pnpm', + 'Setup Node.js', + "Use external node-gyp to avoid pnpm's bundled copy", + 'Install dependencies' + ].includes(step.name) + ) + expect(legacy).toHaveLength(4) + expect(enabled(current)).toBe(shared === 'true') + expect(legacy.every((step) => enabled(step) === (shared !== 'true'))).toBe(true) + expect(current.with).toEqual({ + 'native-runtime': 'electron', + 'cache-electron-package': 'true', + 'cache-pnpm-store-lookup-only': 'true' + }) + expect(legacy.at(-1).run).toBe('pnpm install --frozen-lockfile') + expect(steps.find((step) => step.name === 'Run terminal scale perf report gate').run).toContain( + 'pnpm run test:e2e:terminal-perf:scale:report' + ) +}) diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index c64df18fa899..f782334628d0 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -65,6 +65,16 @@ export function nodeGypRebuildInvocation( } } +export function nodeGypRebuildTimeoutMs( + moduleName, + { platform = process.platform, arch = process.arch, ci = process.env.CI } = {} +) { + // Cold headers and toolchain discovery consumed over four minutes on Windows ARM CI. + return moduleName === 'node-pty' && platform === 'win32' && arch === 'arm64' && ci === 'true' + ? 600_000 + : 300_000 +} + /** The binary the addon actually loads. */ export function windowsProcessTreeAddonPath(packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { return join(packageDir, 'build', 'Release', 'windows_process_tree.node') diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index 73756af16591..620deba58c4e 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -16,6 +16,7 @@ import { assertWindowsProcessTreeRuntimeCreationTime, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, WINDOWS_PROCESS_TREE_PACKAGE_DIR @@ -54,6 +55,33 @@ describe('windows-process-tree node-gyp rebuild', () => { }) }) + it('allows cold setup and compilation only for node-pty on a Windows ARM CI host', () => { + expect( + nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'arm64', ci: 'true' }) + ).toBe(600_000) + }) + + it.each([ + { moduleName: 'node-pty', platform: 'win32', arch: 'x64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'linux', arch: 'arm64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'darwin', arch: 'arm64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: 'false' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '1' }, + { moduleName: '@orca/windows-registry', platform: 'win32', arch: 'arm64', ci: 'true' }, + { moduleName: '@vscode/windows-process-tree', platform: 'win32', arch: 'arm64', ci: 'true' } + ])('keeps the five-minute bound for $moduleName on $platform/$arch with CI=$ci', (host) => { + expect(nodeGypRebuildTimeoutMs(host.moduleName, host)).toBe(300_000) + }) + + it('uses the execution host rather than an ARM cross-compilation target', () => { + const { args } = nodeGypRebuildInvocation('arm64', import.meta.dirname) + expect(args).toContain('--arch=arm64') + expect( + nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'x64', ci: 'true' }) + ).toBe(300_000) + }) + it('copies node-addon-api headers into the patched include dir', () => { const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-')) try { diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index fd89c22f66c4..44edcfd90b7e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -4,6 +4,7 @@ "../src/cli/**/*", "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", + "../src/main/gitlab/project-ref-parser.ts", "../src/main/agent-hooks/grok-replay-guard.ts", "../src/main/claude/hook-script.ts", "../src/main/claude/claude-hook-event-versions.ts", @@ -223,6 +224,9 @@ "../src/main/hermes/hook-service.ts", "../src/main/git-bash.ts", "../src/main/in-flight-run-dedupe.ts", + "../src/main/jcode/hook-settings.ts", + "../src/main/jcode/hook-config.ts", + "../src/main/jcode/hook-service.ts", "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", "../src/main/dsh/dsh-home-patch.ts", @@ -237,6 +241,7 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + "../src/main/startup/shell-path-probe.ts", "../src/main/startup/windows-shell-path-ownership.ts", // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 64cf400f51bb..557299f72590 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 88m + + downloads: 89m @@ -15,7 +15,7 @@ downloads downloads - 88m - 88m + 89m + 89m diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index da444138afe0..8130b770dd94 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1815,6 +1815,49 @@ received the signal after both builders finished. The qualifying trial requested normal cancellation earlier in the same preparation sequence to account for observed delivery delay; no workload, wait or proof predicate was shortened. +## October 3 Terminal Perf dependency preparation + +The daily/manual Terminal Perf workflow still installed current dependencies through +raw lifecycle scripts and a global node-gyp installation. Its historical `ref` +input also accepts revisions that lack the shared installer, so replacing that +path unconditionally would break older runs. The current-profile path now uses +the existing shared installer with explicit Electron preparation and archive +caching. A guard requires GitHub-hosted Linux x64, Node 24/pnpm 12.8.1, the +native-only root postinstall and the needed local action inputs/files. Other +profiles and historical revisions keep their original frozen install. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37101695800) +ran both preparation paths in each of two Linux x64 jobs, reversing their order. +Legacy/shared preparation took 25.164/16.956 seconds and 27.434/18.032 seconds: +8.208 and 9.402 seconds saved. Both used Node 24.21.0, pnpm 12.8.1 and Electron +43.7.5. Both shared native-module cache lookups missed, so this improvement did +not depend on a warm native build. Electron archive and root pnpm cache lookups +hit. Dependency trees, pnpm data and Electron archives were reset between paths; +compiler headers and external services were not. Bootstrap, resets, validation, +post-job cleanup, queueing and the production guard step are outside those times. +These are preparation measurements, not whole-workflow or billing savings. + +Both paths passed a native-module probe inside the actual Electron executable +with `ELECTRON_RUN_AS_NODE=1`, and built the same Electron-vite e2e application. +The candidate's 18 focused routing/fallback tests, workflow actionlint and changed +code-quality checks passed. Performance tests, budgets and report uploads remain +unchanged. The [existing October 2 run](https://github.com/stablyai/orca/actions/runs/36985792125) +failed the same-workspace 50/100-terminal budgets (46.9/50.2 ms against 25 ms). +This dependency change does not claim to resolve those application regressions. + +The [full candidate integration](https://github.com/stablyai/orca/actions/runs/37104625474) +passed on `df71ad849cd854a232f7063562785563743b641a`: current preparation was +selected, its native cache missed and rebuilt, the app built and all 32 report +annotation rows passed the unchanged budget checker. The downloaded report also +passed the same checker locally. This is integration evidence; it does not +attribute application latency changes to dependency preparation. Subsequent +rebases resolved report documentation and incorporated fixture teardown fixes. +Workflow, installer-action and toolchain content stayed unchanged. Main also +added an import and a Windows-only MSBuild setting to the native-runtime script: +the imported helper has no top-level side effects, and the Linux rebuild branch +is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR +checks qualify separately. + ## October 3 producer follow-up: automatic selection for the measured profile The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) @@ -1851,3 +1894,68 @@ header placement, then its empty cache-miss output), and are excluded. This prov automatic selection and cold publication, not a new timing result. Local verification passed eight suites / 184 tests, the changed-code quality gate and compiled-composite actionlint. + +## October 3 retired-cache collection observation + +The same owner-collection assertion failed in unit shard 3 of +[37098089274](https://github.com/stablyai/orca/actions/runs/37098089274/attempts/1) +and [37100365037](https://github.com/stablyai/orca/actions/runs/37100365037/attempts/1), +requiring a full shard retry despite the focused suite passing locally. Its +three-turn collection budget was shorter than the six-turn plus final yield +pattern already used by the GitLab known-host retirement tests. + +The fixture now uses that existing observation budget. All seven tests, their assertions, +expiry clocks and production code are unchanged. The focused suite passes. A +local fault control changed only the production timer callback to hold its owner +strongly: the owner-collection assertion failed, with the other six tests passing. +The source was restored afterward. Extra collection turns therefore preserve the +strong-retention oracle. Hosted qualification is still required; these observations +do not prove a particular VM-retention cause or quantify avoided retries. + +## October 3 unit-selection evidence: include failed references + +The caller's `needs.test.result == 'success'` condition prevented the advisory +collector from reading failed unit runs, despite the reviewer's existing support +for failed tests. A six-run screen from the October 3 occupancy sample found only +one review artifact; it was a full fallback, so it did not validate selection. +Missing artifacts cannot establish that selection catches red tests. + +The caller now permits both success and failure while excluding cancellation and +skipped tests. The collector remains advisory and absent from `verify` dependencies. +Incomplete, interrupted or inconsistent shard records still cannot become complete +reference evidence. Existing omitted-failure tests preserve that negative control. + +The five artifacts from failed [run 37098089274, attempt 1](https://github.com/stablyai/orca/actions/runs/37098089274/attempts/1) +were reviewed locally using the unchanged script. It recognized a complete failed +reference covering 10,606 files and 9,270,307 worker-ms. Its candidate was the full +fallback, so `selectionEvaluated` remained false and no selection promotion is +justified by this control. Focused workflow/reviewer checks passed 24 tests, +including actual caller-expression outcomes for success, failure, skipped and +cancelled states. This repair supplies needed evidence for a later optimization; +it claims no runner-time savings and does not enable selected tests. + +The updated caller also passed the hosted red-run control in +[37100365037](https://github.com/stablyai/orca/actions/runs/37100365037). +The collector succeeded after one unit shard failed, while required verification +remained red. Its review recognized all five shards as a complete reference +(10,608 files, 8,965,977 worker-ms). This was again a full fallback with +`selectionEvaluated: false`, not evidence for enabling selected tests. + +## October 3 removal fixture cleanup ordering + +[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358) +failed unit shard 4 with `ENOTEMPTY` removing the failed-removal fixture's temporary +directory; the other four shards passed. A client's removal reply intentionally +precedes the detached job's final record persistence. This fixture reset tracking +and removed the directory before waiting for that persistence, allowing a writer +to race cleanup. Its teardown now awaits the existing settlement helper before +resetting tracking or deleting the fixture. Production removal behavior and all +assertions are unchanged. + +All 1,348 runtime tests passed (one existing skip). A temporary controlled queue +held the final record write after the client replied: waiting before reset stayed +pending and passed; resetting before waiting lost the tracked job and failed the +same ordering assertion. The gate was released, both controls drained the captured +job, and the instrumentation was removed. Changed-code quality passed. This proves +the teardown ordering mechanism, not a measured avoided-retry saving. Final-head +hosted qualification remains required. diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md new file mode 100644 index 000000000000..f5c5c03b8156 --- /dev/null +++ b/docs/reference/jcode-hook-events.md @@ -0,0 +1,147 @@ +# jcode hook events + +What jcode actually emits, and why Orca's status mapping is shaped the way it is. +Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every +`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to +a log, then running real turns. Re-capture before changing the mapping; do not +edit it from memory. + +## The six events + +jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle +points. Five are **observers** — detached, fire-and-forget, they can never slow +the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input +to its stdin, and waits for it to exit before the tool runs. + +| Event | When | Orca state | Notable payload fields | +| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- | +| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | +| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | +| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | +| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | +| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | +| `session_end` | session closed | `done` | `source` = `close` | + +`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping +it to `working` would spin before the user has typed anything (same reason Devin +does not map its `SessionStart`). + +## Captured payloads + +A `jcode run` turn that read one file and wrote another: + +```json +{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"} +{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"} +{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"} +{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"} +{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"} +``` + +A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit): + +```json +{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"} +{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"} +{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"} +``` + +Three consequences the mapping depends on: + +- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm + workers, headless sessions), not `jcode run`. It is what fills the otherwise + blank window between a submitted prompt and the first tool call. +- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the + outcome, so the tool preview has to be held from the matching `pre_tool`. +- **Every jcode tool schema has an `intent` string** the model fills in. It is + the preview fallback when no tool-specific key (`file_path`, `command`, …) + matches. + +## Why Orca subscribes to the gate + +`pre_tool` is the only event that can report a tool *while it runs*. Without it a +three-minute `bash` shows no tool at all until it finishes. Two rules keep the +gate from ever costing the agent anything: + +1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits + for the process *and* reads its stderr to EOF — a backgrounded child that + inherited stderr would hold the gate open for as long as it ran. The managed + script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the + inherited pipes are closed and the script exits immediately. +2. **stdin is drained first.** jcode `write_all`s the full tool input to the + hook's stdin. A tool input larger than the pipe buffer (a big `write`) would + block that write until the gate timed out if nobody read it, so the script + drains stdin before any exit path. + +Orca never blocks a jcode tool call: the script always exits 0. + +## Questions and permissions + +jcode has **no interactive per-tool approval prompt**. Its safety model +(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a +command outright or asks the model to justify it — both inside the tool, with no +human in the loop. There is therefore no hook, and no terminal-title state, for +"jcode is waiting on you" during ordinary tool use. + +The one tool a *human* answers is ambient mode's `request_permission` +(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with +`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the +tool input as the question card. `post_tool` for the same tool is *not* mapped — +by then the human has already answered. + +Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, +bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail, +integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory, +multiedit, open, panel, patch, read, schedule, session_search, side_panel, +skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools; +a substring rule over that set would be matching on coincidence. + +## Terminal titles + +jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI +session: + +``` +jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s +``` + +The format is ` jcode [ · +N -M][ · work|last ~]` +(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar +identity only — status comes from hooks, never from a parsed title. Note there is +no "needs input" title state; that is the same gap as above, not an omission in +the parser. + +## Per-pane daemons + +jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire +*inside the daemon*. Every TUI client connects the daemon the first pane started, +so without isolation a second jcode pane's events carry the first pane's +`ORCA_PANE_KEY` and its status lands on the wrong tab. + +jcode does forward a client's terminal identity to hooks +(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but +that allowlist covers tmux/zellij/herdr and the terminal emulators — not +`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so +each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of +the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a +full pane key never fits. + +## Config shape + +`[hooks]` values accept a string or an array of strings (`HookCommands` in +`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload, +so hooks can be added without restarting. jcode parses a hook command line +shell-style but **executes it directly, not through a shell** — the managed value +must be the script path, never an `if [ -f … ]` wrapper. + +That shell-style parse is `parse_hook_command` +(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path +**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every +unquoted backslash as an escape, so a bare Windows path reaches `exec` as +`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home +with a space splits into two arguments. Single quotes pass a path through +verbatim — backslashes are literal inside them — so Orca single-quotes by +default and falls back to double quotes (escaping `\` and `"`) only for a path +that itself contains a single quote. The value is then TOML-quoted on the way +into the file, so neither the raw path nor the shell-quoted string appears +alone. diff --git a/docs/reference/monaco-language-associations.md b/docs/reference/monaco-language-associations.md index a159477e1761..ee985d859597 100644 --- a/docs/reference/monaco-language-associations.md +++ b/docs/reference/monaco-language-associations.md @@ -5,7 +5,11 @@ languages and loads their grammars on demand. Filename detection must not load t editor itself: it also runs during session restoration and before the editor mounts. `monaco-language-associations.json` contains the registration metadata from the -installed package's entry point. Regenerate it after upgrading Monaco: +installed package's entry point, plus curated Ruby associations in the generator. +Those add `.rake`, `.ru`, `.jbuilder`, `.thor`, `Guardfile`, `Capfile`, `Podfile`, +`Brewfile` and `Vagrantfile` to the existing Ruby grammar. Change these in +`config/scripts/generate-monaco-associations.mjs`, not the generated JSON. +Regenerate after changing the curated associations or upgrading Monaco: ```sh node config/scripts/generate-monaco-associations.mjs @@ -13,7 +17,7 @@ pnpm exec oxfmt --write src/renderer/src/lib/monaco-language-associations.json ``` The generator reads syntax trees without executing contributions or grammar loaders. -Its test compares the checked-in metadata to the installed package. The original +Its test compares the checked-in metadata to the installed package and curated associations. The original list was verified against a clone of `microsoft/monaco-editor`, tag `v0.55.1`, commit `516f350bdaf7a82f6731bd128a9ec86a6e5fa47d` (`src/basic-languages` and `src/language`). diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx index 6d2f07928561..b9aecde4fa31 100644 --- a/docs/site/content/docs/cli/overview.mdx +++ b/docs/site/content/docs/cli/overview.mdx @@ -38,8 +38,11 @@ orca status --json ``` orca worktree ps --json orca worktree create --repo id: --name my-task --issue 123 --json +orca worktree create --repo id: --name review-pr-123 --pr 123 --json orca worktree current --json orca worktree set --worktree active --comment "reproduced bug" --json +orca worktree set --worktree active --gitlab-issue 42 --gitlab-mr 77 --json +orca worktree set --worktree active --pr null --json orca worktree rm --worktree id: --force --json ``` diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index d4cb4346d652..087caf0e02b4 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -97,11 +97,14 @@ orca worktree show --worktree active --json orca worktree create --repo id: --name fix-login --json orca worktree create --name child-task --agent codex --prompt "Investigate the flaky login test" --json orca worktree set --worktree active --comment "reproduced failure; testing token refresh fix" --json +orca worktree set --worktree active --workspace-status in-review --unread --json orca worktree rm --worktree id: --force --json ``` When `worktree create` runs from inside an Orca-managed worktree, Orca records the new worktree as a child when it can infer the relationship. Pass `--parent-worktree active` to be explicit, or `--no-parent` when the new work is independent. +`worktree set --unread` puts the unread dot on the workspace in the sidebar, the same one Orca shows when an agent finishes; `--read` clears it. + Agent startup flags: ```bash @@ -112,6 +115,22 @@ orca worktree create --name hidden-setup --setup inherit --json `--agent` launches the selected agent in the first terminal. `--prompt` sends initial work to that agent. `--setup run|skip|inherit` controls repo setup hooks; `inherit` follows the repo policy. +Link issues and reviews using the existing workspace metadata: + +```bash +orca worktree create --repo id: --name review-task --pr 123 --json +orca worktree create --repo id: --name gitlab-task --gitlab-issue 42 --gitlab-mr 77 --json +orca worktree set --worktree active --gitlab-issue '#42' --gitlab-mr '!77' --json +orca worktree set --worktree active --gitlab-issue https://gitlab.example.com/group/project/-/work_items/42 --json +orca worktree set --worktree active --pr null --gitlab-mr null --json +``` + +`--pr` is a GitHub pull request number. `--gitlab-issue` accepts an issue number or `#42`; `--gitlab-mr` accepts a merge request number or `!77`. Numbers must be positive safe integers. The GitLab flags also accept HTTP(S) issue or merge request URLs, including self-hosted instances and nested groups. The URL's host and project must match the workspace's stored GitLab source context or the repository's stored remote. If that identity is missing or different, the command fails before updating metadata. A URL cannot choose another project, change the checkout, or fetch a review branch. + +Omitting a flag leaves its link unchanged. On `set`, literal `null` clears only the named link; `create` refuses `null`. Each provider has separate fields, so setting a GitLab issue or merge request preserves GitHub and Linear links. + +Folder-based repositories can store numeric links through these commands, but a number does not supply a provider host or project. Without existing source context or a stored remote, pasted GitLab URLs are refused and provider details or clickable links may be unavailable. These flags use fields already supported by the runtime; an older runtime that predates a field may ignore it, so check `worktree show --json` after writing to an older host. + ## Terminals ```bash diff --git a/docs/site/content/docs/model/meta.json b/docs/site/content/docs/model/meta.json index 0acd89b80e3c..9ef9d2bfd9df 100644 --- a/docs/site/content/docs/model/meta.json +++ b/docs/site/content/docs/model/meta.json @@ -1,5 +1,12 @@ { "title": "The Orca Model", "defaultOpen": true, - "pages": ["worktrees", "tabs-panes-splits", "agents-sessions", "session-restore", "quick-open"] + "pages": [ + "worktrees", + "orca-yaml", + "tabs-panes-splits", + "agents-sessions", + "session-restore", + "quick-open" + ] } diff --git a/docs/site/content/docs/model/orca-yaml.mdx b/docs/site/content/docs/model/orca-yaml.mdx new file mode 100644 index 000000000000..0cc790121b2f --- /dev/null +++ b/docs/site/content/docs/model/orca-yaml.mdx @@ -0,0 +1,151 @@ +--- +title: orca.yaml & .worktreeinclude +description: Local worktree setup, terminal defaults, shared directories, and copied files. +--- + +Put `orca.yaml` and `.worktreeinclude` at the repository root. `orca.yaml` supplies project defaults; `.worktreeinclude` lists ignored files to carry into new worktrees. Commit these configuration files so other users can use the same rules. Keep secrets in the ignored files they refer to. + +This reference covers **local Git worktrees**. [Folder workspaces](/docs/model/worktrees#multi-repo-project-groups--folder-workspaces) do not create a Git checkout or run these copy/share steps. For other execution targets, see [Ways to run Orca](/docs/ways-to-run). + +## Example + +For a project that uses pnpm, this installs dependencies during setup and opens an agent tab and a Git status tab: + +```yaml +scripts: + setup: | + pnpm install + archive: | + echo Workspace archived +setupAgentStartupPolicy: wait-for-setup +defaultTabs: + - title: Agent + - title: Git status + command: git status --short +worktree: + sharedDirectories: + - .cache +``` + +The `.cache` directory must already exist and be gitignored in the primary checkout. The first tab deliberately has no command: when the desktop create composer launches an agent, its startup takes precedence over the first tab's configured command. + +## Accepted keys and defaults + +All keys are optional. Script, title, command, and path values must be strings; Orca trims them and drops empty or oversized values. Invalid fields are skipped while valid siblings still apply. Unknown keys do not configure additional behavior. + +| Key | Accepted value | When omitted | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `scripts.setup` | Nonempty script string, including a YAML block scalar (`\|`). Runs in the new worktree when setup is enabled. | No project setup script. | +| `scripts.archive` | Nonempty script string. Runs in the worktree before archive/removal when hooks are enabled. | No project archive script. | +| `setupAgentStartupPolicy` | Exactly `start-immediately` or `wait-for-setup`. | `start-immediately`; a local **wait** setting still takes precedence. | +| `issueCommand` | Nonempty command template for linked GitHub/GitLab items in the create composer. | No project template; the composer can still supply its built-in agent prompt. | +| `defaultTabs` | List of mappings with optional `title`, `command`, and `color`. At least one valid field is required per entry. `color` accepts `#RGB` or `#RRGGBB`. | Normal initial terminal behavior. | +| `worktree.sharedDirectories` | List of repository-relative directory paths. Only existing, gitignored directories are shared. | Only the user's Settings shared paths apply. | +| `environmentRecipes` | List of per-workspace environment recipes. See [Cloud VMs](/docs/ways-to-run#4-cloud-vms-per-workspace-environments) and the `orca-per-workspace-env` skill for recipe fields and lifecycle commands. | No project recipes. | + +`scripts` and `worktree` must be mappings; `defaultTabs`, `environmentRecipes`, and `sharedDirectories` must be lists. `setupRunPolicy` and `commandSourcePolicy` are **Settings values**, not accepted `orca.yaml` keys. + +### Parse failures and limits + +**Invalid YAML or a duplicate mapping key anywhere rejects the whole file.** Orca does not keep the last duplicate value. A non-mapping root, excessive alias expansion, or an oversized file also produces no configuration. A YAML checker that accepts duplicate keys does not establish that Orca will accept the file. + +| Limit | Result when exceeded | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Whole file: 256 KiB (262,144 UTF-8 bytes) **and** 262,144 UTF-16 code units | Whole file rejected. | +| Each string field: 64 KiB (65,536 UTF-8 bytes) **and** 65,536 UTF-16 code units, measured before trimming | Field dropped. | +| `defaultTabs`: 256 input entries | Entire tab list dropped. | +| `environmentRecipes`: 256 input entries | Entire recipe list dropped, with a recipe diagnostic. | +| `worktree.sharedDirectories`: first 100 input entries | Later entries ignored, even if earlier entries were invalid or duplicates. | +| YAML alias expansion: parser `maxAliasCount` of 100 | Whole file rejected if the parser's expansion budget is exceeded; this is not a simple count of alias tokens. | + +## Which checkout supplies the configuration? + +The **primary checkout** is the repository folder registered in Orca. It may be on a different branch from the new worktree. + +| Configuration | Read from | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Setup script, setup startup policy, and `defaultTabs` | The **new worktree's** `orca.yaml`. | +| Shared directory rules and their source directories | The **primary checkout's** `orca.yaml` and filesystem. | +| Copy rules and their source files | The **primary checkout's** `.worktreeinclude` and filesystem. | +| Archive script | The **primary checkout's** `orca.yaml`, executed with the worktree as its working directory. | +| Issue command | The **primary checkout's** `.orca/issue-command`, then its `orca.yaml`. | + +Changing a feature branch's `.worktreeinclude` or shared-directory list does not update the primary checkout. Before creating another worktree, make sure the primary checkout has the intended rules and the ignored source paths. Setup and tab defaults instead follow the revision checked out in the new worktree. These defaults are applied during creation; they do not synchronize existing tabs or files. + +## Setup, archive, and command selection + +In **Settings → Repository**, setup can run automatically, ask each time, or be skipped by default. A new repository defaults to running setup. The [CLI](/docs/cli/reference#worktrees) accepts `--setup run|skip|inherit`; `inherit` follows that Settings policy. A valid file does not bypass Orca's command approval. + +**Command source & orca.yaml** controls setup and archive scripts: + +| Settings choice | Scripts used | +| ---------------------------------- | ----------------------------------------------- | +| **orca.yaml only** (`shared-only`) | Only the YAML hook; a local hook is ignored. | +| **Local only** (`local-only`) | Only the local hook; the YAML hook is ignored. | +| **Run both** (`run-both`) | YAML first, then local, joined into one script. | + +When no source choice is saved, a nonempty local hook selects **Local only** for that hook; otherwise Orca uses **orca.yaml only**. Shared directories are independent of this command-source choice. + +Setup runs in a **Setup** terminal. On macOS/Linux, Orca writes a Bash runner with `set -e`; supported shell options on a leading `#!` line are replayed. On native Windows, the runner uses `.cmd` syntax and calls each nonempty line, stopping on failure. A leading POSIX-shell `#!` line opts into Bash only when Git Bash is configured and available; otherwise the `.cmd` runner refuses the script before running any commands. Choosing PowerShell as the terminal does not turn a setup script into PowerShell code. + +The setup runner receives: + +| Variable | Value | +| ----------------------------------------- | ----------------------------------------------------------------- | +| `ORCA_ROOT_PATH` | Primary checkout path. | +| `ORCA_WORKTREE_PATH` | New worktree path. | +| `ORCA_WORKSPACE_NAME` | Worktree directory basename, rather than a renamed display title. | +| `CONDUCTOR_ROOT_PATH`, `GHOSTX_ROOT_PATH` | Compatibility aliases for `ORCA_ROOT_PATH`. | + +Paths and shell syntax differ by platform. Use `$ORCA_WORKTREE_PATH` in Bash and `%ORCA_WORKTREE_PATH%` in `.cmd` scripts. `wait-for-setup` waits for successful setup before agent startup; either the YAML or local wait setting enables it. This controls startup order, not whether setup runs. + +Archive hooks run before local removal; the CLI requires `--run-hooks` to enable them. A failed archive hook blocks removal unless the caller explicitly accepts that failure. + +### Terminal defaults and issue commands + +`defaultTabs` creates terminal tabs once for the new worktree. Titles and colors still apply when commands are skipped. Tab commands follow the setup run decision and are suppressed by **Local only** (using the setup command-source policy). + +In the **desktop create composer**, an agent/startup command takes the first template tab, so the **first tab's template command is not run**. Reserve that tab for the agent and put other commands in later tabs. Local CLI creation with `--agent` instead creates a separate startup terminal alongside the configured tabs; every allowed template command can run. Tab commands do not wait for setup to finish, so a command that needs installed dependencies should be run after setup completes. The example's Git status command does not depend on the install. + +`issueCommand` has its own override: nonempty `.orca/issue-command` content wins over YAML. Clearing that local file restores the shared template. This is separate from the setup/archive command-source choice. Templates support `{{artifact_url}}` for the linked item's URL and legacy `{{issue}}` for its number; the composer decides whether to use the template. Shared YAML commands use Orca's approval flow, which can reuse saved trust; local overrides are treated as user-authored and do not trigger that shared-command prompt. + +```yaml +issueCommand: | + echo "Linked item: {{artifact_url}}" +``` + +## Sharing versus copying ignored paths + +All three mechanisms take paths relative to the primary checkout and preserve an existing destination. Settings paths are applied first, then YAML shared directories, then include copies. A path already present through sharing is not copied again. + +| Mechanism | Source entries | Result in a new local worktree | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| **Settings → Repository → Worktree Shared Paths** | Personal repository setting; existing files or directories. | APFS clone-copy on macOS when available; otherwise a link. A clone has independent contents, while a link shares edits. | +| `worktree.sharedDirectories` | Existing, **gitignored directories** listed in the primary `orca.yaml`. Adds to Settings paths. | Always a link to the primary directory, including on APFS. Windows tries a directory junction before a symlink. Edits affect the shared source. | +| `.worktreeinclude` | Existing, **gitignored files or directories** listed in the primary checkout. | Private copy, using APFS clone-copy when available and ordinary copying otherwise. It never falls back to a shared link. | + +YAML shared paths normalize backslashes to `/`, remove a leading `./` and trailing `/`, and deduplicate. Absolute paths, drive-prefixed paths, empty path segments, `.`/`..` segments, and any `.git` segment are rejected. A file, missing directory, or non-ignored directory is skipped. + +### .worktreeinclude format and copy budget + +```text +# .worktreeinclude +.env +.env.local +.vscode/settings.json +``` + +Use **one literal path per line**, anchored to the repository root. Blank lines and lines starting with `#` are ignored; inline comments are not stripped. Backslashes, a leading `./`, and trailing `/` are normalized and duplicates are removed. Glob patterns containing `*` or `?` and negation starting with `!` are skipped. Use relative paths without traversal or `.git`; only existing, gitignored entries are copied. The include file must be a regular file no larger than 256 KiB; Orca considers at most 1,000 valid literal path candidates. + +Ordinary include copying is limited to **2 GiB total file bytes and 50,000 filesystem entries** per new worktree, measured before copying. APFS clones do not consume the byte budget, but still consume the entry budget; a fallback to ordinary copying must fit the byte budget. An entry that exceeds the remaining budget is skipped and creation reports a warning. Earlier refused entries can also exhaust the bounded sizing walk, leaving later entries unmeasured. These are admission limits, not quotas against files growing during copying. + +Copying a top-level source symlink uses its target's contents. Nested symlinks remain links, so editing through one can still affect its referent. Large dependency trees usually belong in setup or deliberate sharing rather than `.worktreeinclude`. + +## When configuration appears to do nothing + +1. Check the correct checkout from the table above, the exact key spelling, value type, duplicate keys, and limits. A parser failure can disable setup, tabs, and shared directories together. +1. Check setup's run policy, command-source choice, approval, and first-tab startup behavior. `--setup run` changes the run decision; it does not override **Local only**. +1. Check that source paths exist in the primary checkout and are directories for `sharedDirectories`. Verify ignored status there with `git check-ignore -- path/to/entry`; tracked or unignored paths do not qualify for YAML sharing or include copying. +1. Check for an existing destination or a copy-budget warning. Missing shared directories are skipped without a warning; not every skipped field/path has a visible error. Absence of a warning does not prove acceptance. + +See [Worktrees](/docs/model/worktrees#shared-directories--gitignored-files) for the creation flow and [Settings](/docs/settings#repository) for repository preferences. diff --git a/docs/site/content/docs/model/worktrees.mdx b/docs/site/content/docs/model/worktrees.mdx index f71d21c2ca44..c90cf4513516 100644 --- a/docs/site/content/docs/model/worktrees.mdx +++ b/docs/site/content/docs/model/worktrees.mdx @@ -42,11 +42,13 @@ A brand-new worktree is a clean checkout. Dependencies, caches, and local secret Orca fills that gap in three complementary ways: 1. **Worktree Shared Paths** (per repo, in Settings → Repository) — paths materialize from the primary checkout into each new worktree (APFS clone-copy on macOS when possible, otherwise a symlink). -1. **`worktree.sharedDirectories` in `orca.yaml`** — repo-checked-in list of **gitignored directories** to share the same way (symlink/share, not copy). Use this for large rebuildable trees like `node_modules` or `.cache`. Entries must exist as directories in the primary checkout **and** be gitignored; tracked or missing paths are skipped. +1. **`worktree.sharedDirectories` in `orca.yaml`** — repo-checked-in list of **gitignored directories** to share by link (including on APFS). Use this for large rebuildable trees like `node_modules` or `.cache`. Entries must exist as directories in the primary checkout **and** be gitignored; tracked or missing paths are skipped. 1. **`.worktreeinclude` at the repo root** — list of **gitignored files or directories to copy** (not symlink) into each new worktree, so each worktree owns its copy. Typical entries: `.env`, local config under `.vscode/`. Blank lines and `#` comments are allowed. Only **literal** paths are supported today — globs and negation are skipped with a warning. Paths that are tracked, missing, or not gitignored are not copied. `orca.yaml` shared directories **add to** the per-user Worktree Shared Paths list; they never replace it. Paths already shared/linked are not re-copied from `.worktreeinclude`. +See the [orca.yaml & .worktreeinclude reference](/docs/model/orca-yaml) for setup scripts, default tabs, command selection, path rules, limits, and which checkout supplies each setting. + ```yaml # orca.yaml (repo root) worktree: diff --git a/docs/site/content/docs/settings.mdx b/docs/site/content/docs/settings.mdx index a5bbe9b83f48..1bf6dc794343 100644 --- a/docs/site/content/docs/settings.mdx +++ b/docs/site/content/docs/settings.mdx @@ -44,6 +44,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then - Ghostty import. - Warp theme import — bring in your Warp YAML themes with **Import themes from Warp** (auto-discovers Warp's themes folder per OS) or **Import from YAML** for any folder of Warp-format theme files. - JIS Yen (¥) to Backslash (\\) for macOS Japanese keyboards. +- **Terminal shell** (local macOS / Linux) — system shell or a custom executable, with login startup by default. **Custom shell → Advanced → Custom args** requests replacement arguments for ordinary local panes. See [startup files and argument limits](/docs/terminal#macos-and-linux-shell). - Windows default shell (PowerShell or CMD). - **Allow TUI Clipboard Writes (OSC 52)** — **on by default**. Lets Zellij, tmux, Neovim, fzf, Grok (and similar) write the system clipboard over the PTY, including over SSH. Turn off if you prefer the older lockdown. diff --git a/docs/site/content/docs/terminal.mdx b/docs/site/content/docs/terminal.mdx index 1be3d3892f3e..8db8b24caee7 100644 --- a/docs/site/content/docs/terminal.mdx +++ b/docs/site/content/docs/terminal.mdx @@ -46,6 +46,23 @@ If you've collected themes in Warp, click **Import themes from Warp** in the ter Imported themes appear alongside Orca's built-ins in the theme dropdown. +## macOS and Linux shell + +By default, local terminal panes on macOS and Linux open your system shell (`$SHELL`) as a **login shell** (`-l`). With the default arguments: + +- **zsh** reads `.zshenv`, `.zprofile`, `.zshrc`, and `.zlogin` in order from `$ZDOTDIR`, or your home directory (`~`) if unset. Each user file follows its system counterpart (`zshenv`, `zprofile`, `zshrc`, `zlogin`), usually under `/etc` or `/etc/zsh`. +- **bash** reads `/etc/profile`, then the first of `~/.bash_profile`, `~/.bash_login`, or `~/.profile` that exists. It does **not** read `~/.bashrc` on its own. If your `PATH` or version-manager setup (nvm, asdf, mise) lives in `~/.bashrc`, source it from that login file, as many distributions' default `~/.profile` or `~/.bash_profile` already do: + + ```bash + [ -n "${BASH_VERSION:-}" ] && [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc" + ``` + + The guard keeps non-bash shells that also read `~/.profile` from loading bash-only setup, even with `set -u` enabled. Add it only if the login file bash reads does not already source `~/.bashrc`. + +When Orca uses its bash integration wrapper, it starts bash with `--rcfile` instead of `-l`. The wrapper sources the same login files without separately sourcing `~/.bashrc`; bash itself is not in login mode. + +To open a different shell, choose **Custom shell** under [Settings → Terminal → Terminal shell](/docs/settings). Under **Advanced → Custom args**, enter one argument per line to replace the default `-l` for ordinary local panes. An empty list requests no arguments: unwrapped interactive bash then reads `~/.bashrc` instead of the login files. When the terminal daemon is unavailable, Orca's bash or zsh integration can override custom arguments and retain login startup. Agent launches, startup commands, and one-off shell choices do not use this argument setting. + ## Windows shell The default shell on Windows is configurable between PowerShell, Command Prompt, and WSL under [Settings → Terminal](/docs/settings). WSL is offered automatically when `wsl.exe --status` succeeds. The **+** dropdown on the tab bar also shows a submenu so you can open a one-off tab in any shell without changing your default. diff --git a/docs/site/src/components/docs/SearchDialog.tsx b/docs/site/src/components/docs/SearchDialog.tsx index 36c56dfab65d..e0bc8032c15d 100644 --- a/docs/site/src/components/docs/SearchDialog.tsx +++ b/docs/site/src/components/docs/SearchDialog.tsx @@ -122,6 +122,16 @@ export default function SearchDialog({ dialogId = 'docs-search-dialog', onClose return query.data }, [query.data]) + const highlightedResults = useMemo(() => { + const highlights = new Map() + for (const result of results) { + if (result.content && !highlights.has(result.content)) { + highlights.set(result.content, renderHighlighted(result.content)) + } + } + return highlights + }, [results]) + const selectedIndex = Math.min(activeIndex, Math.max(results.length - 1, 0)) useEffect(() => { @@ -354,7 +364,10 @@ export default function SearchDialog({ dialogId = 'docs-search-dialog', onClose : 'text-muted-foreground hover:bg-accent hover:text-foreground' )} > - {g.page.content ? renderHighlighted(g.page.content) : g.page.url} + {g.page.content + ? (highlightedResults.get(g.page.content) ?? + renderHighlighted(g.page.content)) + : g.page.url} )}