fix: improve cross-platform compatibility (#2064)
This commit is contained in:
parent
84d26fc747
commit
90e49669e6
|
|
@ -91,10 +91,11 @@ class AudioEngine (context: Context) {
|
|||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
requestAudioFocus()
|
||||
|
||||
// Route audio to external device if connected, otherwise route to speaker
|
||||
updateAudioRouting()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// Route audio to external device if connected, otherwise route to speaker.
|
||||
// AudioDeviceInfo/getDevices are API 23+, while the module supports API 21.
|
||||
updateAudioRouting()
|
||||
|
||||
// Listen for changes in audio routing
|
||||
val callback = object:android.media.AudioDeviceCallback(){
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
|
|
@ -110,6 +111,8 @@ class AudioEngine (context: Context) {
|
|||
}
|
||||
audioDeviceCallback = callback
|
||||
audioManager.registerAudioDeviceCallback(callback, null)
|
||||
} else {
|
||||
updateLegacyAudioRouting()
|
||||
}
|
||||
|
||||
val bufferSize = AudioTrack.getMinBufferSize(
|
||||
|
|
@ -136,6 +139,7 @@ class AudioEngine (context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun updateAudioRouting() {
|
||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
var isExternalDeviceConnected = false
|
||||
|
|
@ -177,6 +181,15 @@ class AudioEngine (context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun updateLegacyAudioRouting() {
|
||||
val isExternalDeviceConnected =
|
||||
audioManager.isWiredHeadsetOn ||
|
||||
audioManager.isBluetoothScoOn ||
|
||||
audioManager.isBluetoothA2dpOn
|
||||
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun requestAudioFocus() {
|
||||
val listener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
|
|
|
|||
|
|
@ -710,6 +710,13 @@ app.whenReady().then(async () => {
|
|||
})
|
||||
automations = new AutomationService(store, { claudeUsage, codexUsage })
|
||||
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
|
||||
runtime.setCommitMessageAgentEnvironmentResolvers({
|
||||
prepareForCodexLaunch: () =>
|
||||
store!.getSettings().activeCodexManagedAccountId
|
||||
? codexRuntimeHome!.prepareForCodexLaunch()
|
||||
: null,
|
||||
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
|
||||
})
|
||||
disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({
|
||||
stats,
|
||||
getWindow: () => mainWindow
|
||||
|
|
|
|||
|
|
@ -66,8 +66,10 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './mar
|
|||
import { checkRgAvailable } from './rg-availability'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import {
|
||||
prepareLocalCommitMessageAgentEnv,
|
||||
type CommitMessageAgentEnvironmentResolvers
|
||||
} from '../text-generation/commit-message-agent-environment'
|
||||
|
||||
// Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes
|
||||
// ordinary JSON/log files inaccessible before the editor can degrade features.
|
||||
|
|
@ -93,56 +95,6 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
|
|||
'.pdf': 'application/pdf'
|
||||
}
|
||||
|
||||
export type CommitMessageAgentEnvironmentResolvers = {
|
||||
prepareForCodexLaunch?: () => string | null
|
||||
prepareForClaudeLaunch?: () => Promise<ClaudeRuntimeAuthPreparation>
|
||||
}
|
||||
|
||||
function cloneProcessEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
async function prepareLocalCommitMessageAgentEnv(
|
||||
agentId: string,
|
||||
resolvers: CommitMessageAgentEnvironmentResolvers | undefined
|
||||
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
|
||||
if (!resolvers) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
try {
|
||||
if (agentId === 'codex' && resolvers.prepareForCodexLaunch) {
|
||||
const codexHomePath = resolvers.prepareForCodexLaunch()
|
||||
return {
|
||||
ok: true,
|
||||
env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) {
|
||||
const preparation = await resolvers.prepareForClaudeLaunch()
|
||||
const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, {
|
||||
stripAuthEnv: preparation.stripAuthEnv
|
||||
})
|
||||
return { ok: true, env }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[filesystem] Failed to prepare commit message agent environment:', error)
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Failed to prepare the selected agent account for commit message generation.'
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a buffer appears to be binary (contains null bytes in first 8KB).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ import { registerPreflightHandlers } from './preflight'
|
|||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import {
|
||||
registerFilesystemHandlers,
|
||||
type CommitMessageAgentEnvironmentResolvers
|
||||
} from './filesystem'
|
||||
import { registerFilesystemHandlers } from './filesystem'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import { registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
||||
import { registerClaudeUsageHandlers } from './claude-usage'
|
||||
import { registerCodexUsageHandlers } from './codex-usage'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type * as GitStatusModule from '../git/status'
|
||||
import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation'
|
||||
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getStagedCommitContext: vi.fn(),
|
||||
generateCommitMessageFromContext: vi.fn(),
|
||||
resolveCommitMessageSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/status', async () => ({
|
||||
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
|
||||
getStagedCommitContext: mocks.getStagedCommitContext
|
||||
}))
|
||||
|
||||
vi.mock('../text-generation/commit-message-text-generation', async () => ({
|
||||
...(await vi.importActual<typeof CommitMessageTextGenerationModule>(
|
||||
'../text-generation/commit-message-text-generation'
|
||||
)),
|
||||
generateCommitMessageFromContext: mocks.generateCommitMessageFromContext,
|
||||
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
|
||||
}))
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path,
|
||||
git: {
|
||||
path,
|
||||
branch: 'main',
|
||||
bare: false,
|
||||
detached: false,
|
||||
head: 'a'.repeat(40)
|
||||
}
|
||||
} as unknown as ResolvedRuntimeGitWorktree
|
||||
}
|
||||
|
||||
function makeCommands(worktreePath: string): RuntimeGitCommands {
|
||||
return new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
}
|
||||
|
||||
describe('RuntimeGitCommands', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getStagedCommitContext.mockReset()
|
||||
mocks.generateCommitMessageFromContext.mockReset()
|
||||
mocks.resolveCommitMessageSettings.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const commands = makeCommands(worktreePath)
|
||||
|
||||
await expect(commands.bulkDiscardRuntimeGitPaths('id:wt-1', ['///'])).rejects.toThrow(
|
||||
'invalid_relative_path'
|
||||
)
|
||||
await expect(commands.discardRuntimeGitPath('id:wt-1', '///')).rejects.toThrow(
|
||||
'invalid_relative_path'
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares the selected local agent environment before generating commit messages', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const context = {
|
||||
branch: 'main',
|
||||
stagedSummary: 'M\tREADME.md',
|
||||
stagedPatch: '+hello'
|
||||
}
|
||||
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
||||
mocks.getStagedCommitContext.mockResolvedValue(context)
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'docs: update readme'
|
||||
})
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
||||
getRuntimeSettings: () =>
|
||||
({
|
||||
commitMessageAi: { enabled: true, agentId: 'codex' },
|
||||
agentCmdOverrides: {},
|
||||
enableGitHubAttribution: false
|
||||
}) as GlobalSettings,
|
||||
getCommitMessageAgentEnvironment: () => ({
|
||||
prepareForCodexLaunch: () => '/managed/codex-home'
|
||||
})
|
||||
})
|
||||
|
||||
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'docs: update readme'
|
||||
})
|
||||
|
||||
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
||||
context,
|
||||
params,
|
||||
expect.objectContaining({
|
||||
kind: 'local',
|
||||
cwd: worktreePath,
|
||||
env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- Why: runtime git dispatch stays in one boundary so local, SSH, and runtime-environment behavior remains comparable. */
|
||||
import type {
|
||||
GitBranchCompareResult,
|
||||
GitConflictOperation,
|
||||
|
|
@ -6,10 +7,13 @@ import type {
|
|||
GitStatusResult,
|
||||
GitUpstreamStatus,
|
||||
GitWorktreeInfo,
|
||||
GlobalSettings,
|
||||
Worktree
|
||||
} from '../../shared/types'
|
||||
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
bulkDiscardChanges,
|
||||
bulkStageFiles,
|
||||
bulkUnstageFiles,
|
||||
commitChanges,
|
||||
|
|
@ -18,6 +22,7 @@ import {
|
|||
getBranchCompare,
|
||||
getBranchDiff,
|
||||
getDiff,
|
||||
getStagedCommitContext,
|
||||
getStatus as getGitStatus,
|
||||
stageFile,
|
||||
unstageFile
|
||||
|
|
@ -25,14 +30,37 @@ import {
|
|||
import { getUpstreamStatus } from '../git/upstream'
|
||||
import { gitFetch, gitPull, gitPush } from '../git/remote'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
generateCommitMessageFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
type GenerateCommitMessageResult
|
||||
} from '../text-generation/commit-message-text-generation'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
|
||||
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
|
||||
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
type RuntimeCommitMessageSettingsOverride = Partial<
|
||||
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
|
||||
>
|
||||
|
||||
function normalizeRuntimeGitRelativePath(filePath: string): string {
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
if (relativePath === '') {
|
||||
// Why: git mutation APIs treat an empty pathspec as the worktree root;
|
||||
// runtime RPC must never let malformed file paths discard whole worktrees.
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
return relativePath
|
||||
}
|
||||
|
||||
export type RuntimeGitCommandHost = {
|
||||
resolveRuntimeGitTarget(
|
||||
selector: string
|
||||
): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }>
|
||||
getRuntimeSettings(): GlobalSettings
|
||||
getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined
|
||||
}
|
||||
|
||||
export class RuntimeGitCommands {
|
||||
|
|
@ -52,7 +80,9 @@ export class RuntimeGitCommands {
|
|||
? provider.getStatus(target.worktree.path, options)
|
||||
: provider.getStatus(target.worktree.path)
|
||||
}
|
||||
return options ? getGitStatus(target.worktree.path, options) : getGitStatus(target.worktree.path)
|
||||
return options
|
||||
? getGitStatus(target.worktree.path, options)
|
||||
: getGitStatus(target.worktree.path)
|
||||
}
|
||||
|
||||
async getRuntimeGitConflictOperation(worktreeSelector: string): Promise<GitConflictOperation> {
|
||||
|
|
@ -74,7 +104,7 @@ export class RuntimeGitCommands {
|
|||
compareAgainstHead?: boolean
|
||||
): Promise<GitDiffResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -165,8 +195,8 @@ export class RuntimeGitCommands {
|
|||
oldPath?: string
|
||||
): Promise<GitDiffResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
const oldRelativePath = oldPath ? normalizeRuntimeGitRelativePath(oldPath) : undefined
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -213,9 +243,83 @@ export class RuntimeGitCommands {
|
|||
return commitChanges(target.worktree.path, message)
|
||||
}
|
||||
|
||||
async generateRuntimeCommitMessage(
|
||||
worktreeSelector: string,
|
||||
settingsOverride?: RuntimeCommitMessageSettingsOverride
|
||||
): Promise<GenerateCommitMessageResult> {
|
||||
const resolvedSettings = resolveCommitMessageSettings({
|
||||
...this.host.getRuntimeSettings(),
|
||||
...settingsOverride
|
||||
})
|
||||
if (!resolvedSettings.ok) {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No git provider for connection "${target.connectionId}"`
|
||||
}
|
||||
}
|
||||
let context: CommitMessageDraftContext | null
|
||||
try {
|
||||
context = await provider.getStagedCommitContext(target.worktree.path)
|
||||
} catch (error) {
|
||||
console.error('[runtime-git] Failed to read remote staged commit context:', error)
|
||||
return { success: false, error: 'Failed to read staged changes.' }
|
||||
}
|
||||
if (!context) {
|
||||
return { success: false, error: 'No staged changes to summarize.' }
|
||||
}
|
||||
return generateCommitMessageFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
}
|
||||
|
||||
let context: CommitMessageDraftContext | null
|
||||
try {
|
||||
context = await getStagedCommitContext(target.worktree.path)
|
||||
} catch (error) {
|
||||
console.error('[runtime-git] Failed to read staged commit context:', error)
|
||||
return { success: false, error: 'Failed to read staged changes.' }
|
||||
}
|
||||
if (!context) {
|
||||
return { success: false, error: 'No staged changes to summarize.' }
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
resolvedSettings.params.agentId,
|
||||
this.host.getCommitMessageAgentEnvironment?.()
|
||||
)
|
||||
if (!localEnv.ok) {
|
||||
return { success: false, error: localEnv.error }
|
||||
}
|
||||
return generateCommitMessageFromContext(context, resolvedSettings.params, {
|
||||
kind: 'local',
|
||||
cwd: target.worktree.path,
|
||||
...(localEnv.env ? { env: localEnv.env } : {})
|
||||
})
|
||||
}
|
||||
|
||||
async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
await provider?.cancelGenerateCommitMessage(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
cancelGenerateCommitMessageLocal(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -230,7 +334,7 @@ export class RuntimeGitCommands {
|
|||
|
||||
async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -248,7 +352,7 @@ export class RuntimeGitCommands {
|
|||
filePaths: string[]
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -266,7 +370,7 @@ export class RuntimeGitCommands {
|
|||
filePaths: string[]
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -279,9 +383,27 @@ export class RuntimeGitCommands {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
async bulkDiscardRuntimeGitPaths(
|
||||
worktreeSelector: string,
|
||||
filePaths: string[]
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.bulkDiscardChanges(target.worktree.path, relativePaths)
|
||||
return { ok: true }
|
||||
}
|
||||
await bulkDiscardChanges(target.worktree.path, relativePaths)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -300,7 +422,7 @@ export class RuntimeGitCommands {
|
|||
line: number
|
||||
): Promise<string | null> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
|
||||
const normalizedRelativePath = normalizeRuntimeGitRelativePath(relativePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '
|
|||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
import type { VoiceSettings } from '../../shared/speech-types'
|
||||
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
|
||||
type RuntimeAccountServices = {
|
||||
claudeAccounts: ClaudeAccountService
|
||||
|
|
@ -848,6 +849,7 @@ export class OrcaRuntimeService {
|
|||
private optimisticReconcileTokens = new Map<string, string>()
|
||||
private readonly getLocalProviderFn: (() => IPtyProvider) | null
|
||||
private accountServices: RuntimeAccountServices | null = null
|
||||
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
|
||||
private mobileDictation: {
|
||||
id: string
|
||||
owner: string
|
||||
|
|
@ -1191,7 +1193,9 @@ export class OrcaRuntimeService {
|
|||
)
|
||||
|
||||
private readonly gitCommands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector)
|
||||
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
|
||||
getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings,
|
||||
getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined
|
||||
})
|
||||
|
||||
getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] =
|
||||
|
|
@ -1218,6 +1222,10 @@ export class OrcaRuntimeService {
|
|||
commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind(
|
||||
this.gitCommands
|
||||
)
|
||||
generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] =
|
||||
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
|
||||
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
|
||||
this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands)
|
||||
stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] =
|
||||
this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands)
|
||||
unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] =
|
||||
|
|
@ -1226,6 +1234,8 @@ export class OrcaRuntimeService {
|
|||
this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands)
|
||||
bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] =
|
||||
this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands)
|
||||
bulkDiscardRuntimeGitPaths: RuntimeGitCommands['bulkDiscardRuntimeGitPaths'] =
|
||||
this.gitCommands.bulkDiscardRuntimeGitPaths.bind(this.gitCommands)
|
||||
discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] =
|
||||
this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands)
|
||||
getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] =
|
||||
|
|
@ -1817,6 +1827,12 @@ export class OrcaRuntimeService {
|
|||
this.accountServices = services
|
||||
}
|
||||
|
||||
setCommitMessageAgentEnvironmentResolvers(
|
||||
resolvers: CommitMessageAgentEnvironmentResolvers
|
||||
): void {
|
||||
this.commitMessageAgentEnv = resolvers
|
||||
}
|
||||
|
||||
async startMobileDictation(params: {
|
||||
dictationId: string
|
||||
modelId?: string
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ describe('git RPC methods', () => {
|
|||
getRuntimeId: () => 'test-runtime',
|
||||
stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }),
|
||||
bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }),
|
||||
discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true })
|
||||
discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }),
|
||||
bulkDiscardRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
|
|
@ -101,16 +102,42 @@ describe('git RPC methods', () => {
|
|||
await dispatcher.dispatch(
|
||||
makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.bulkDiscard', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.ts'] })
|
||||
)
|
||||
|
||||
expect(runtime.stageRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts')
|
||||
expect(runtime.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts'])
|
||||
expect(runtime.discardRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts')
|
||||
expect(runtime.bulkDiscardRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts'])
|
||||
})
|
||||
|
||||
it('rejects empty bulk mutation paths before calling the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
bulkDiscardRuntimeGitPaths: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.bulkDiscard', { worktree: 'id:wt-1', filePaths: [''] })
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(response).toMatchObject({
|
||||
error: expect.objectContaining({ code: 'invalid_argument' })
|
||||
})
|
||||
expect(runtime.bulkDiscardRuntimeGitPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes remote operations to the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }),
|
||||
generateRuntimeCommitMessage: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, message: 'feat: test' }),
|
||||
cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }),
|
||||
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
|
||||
} as unknown as OrcaRuntimeService
|
||||
|
|
@ -119,6 +146,10 @@ describe('git RPC methods', () => {
|
|||
await dispatcher.dispatch(
|
||||
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('git.generateCommitMessage', { worktree: 'id:wt-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.push', {
|
||||
worktree: 'id:wt-1',
|
||||
|
|
@ -135,10 +166,68 @@ describe('git RPC methods', () => {
|
|||
)
|
||||
|
||||
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
|
||||
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' })
|
||||
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
|
||||
})
|
||||
|
||||
it('forwards commit-message settings to the runtime', async () => {
|
||||
const commitMessageAi = {
|
||||
enabled: true,
|
||||
agentId: 'codex',
|
||||
selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' },
|
||||
selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' },
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
const agentCmdOverrides = { codex: 'codex --profile work' }
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
generateRuntimeCommitMessage: vi.fn().mockResolvedValue({ success: true, message: 'test' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.generateCommitMessage', {
|
||||
worktree: 'id:wt-1',
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', {
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed commit-message settings before calling the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
generateRuntimeCommitMessage: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.generateCommitMessage', {
|
||||
worktree: 'id:wt-1',
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: 'codex'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(response).toMatchObject({
|
||||
error: expect.objectContaining({ code: 'invalid_argument' })
|
||||
})
|
||||
expect(runtime.generateRuntimeCommitMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects branch diff revisions that are not full object ids', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
|
|
@ -57,8 +58,23 @@ const GitCommit = WorktreeSelector.extend({
|
|||
.pipe(z.string().min(1, 'Missing commit message'))
|
||||
})
|
||||
|
||||
const CommitMessageAiSettings = z.object({
|
||||
enabled: z.boolean(),
|
||||
agentId: z.string().nullable(),
|
||||
selectedModelByAgent: z.record(z.string(), z.string()),
|
||||
selectedThinkingByModel: z.record(z.string(), z.string()),
|
||||
customPrompt: z.string(),
|
||||
customAgentCommand: z.string()
|
||||
})
|
||||
|
||||
const GitGenerateCommitMessage = WorktreeSelector.extend({
|
||||
commitMessageAi: CommitMessageAiSettings.optional(),
|
||||
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
|
||||
enableGitHubAttribution: z.boolean().optional()
|
||||
})
|
||||
|
||||
const GitBulkPaths = WorktreeSelector.extend({
|
||||
filePaths: z.array(z.string())
|
||||
filePaths: z.array(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
const GitPush = WorktreeSelector.extend({
|
||||
|
|
@ -143,6 +159,38 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.commitRuntimeGit(params.worktree, params.message)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.generateCommitMessage',
|
||||
params: GitGenerateCommitMessage,
|
||||
handler: async (params, { runtime }) => {
|
||||
if (
|
||||
params.commitMessageAi === undefined &&
|
||||
params.agentCmdOverrides === undefined &&
|
||||
params.enableGitHubAttribution === undefined
|
||||
) {
|
||||
return runtime.generateRuntimeCommitMessage(params.worktree)
|
||||
}
|
||||
return runtime.generateRuntimeCommitMessage(params.worktree, {
|
||||
...(params.commitMessageAi !== undefined
|
||||
? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] }
|
||||
: {}),
|
||||
...(params.agentCmdOverrides !== undefined
|
||||
? {
|
||||
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
|
||||
}
|
||||
: {}),
|
||||
...(params.enableGitHubAttribution !== undefined
|
||||
? { enableGitHubAttribution: params.enableGitHubAttribution }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.cancelGenerateCommitMessage',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.cancelRuntimeGenerateCommitMessage(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.stage',
|
||||
params: GitFilePath,
|
||||
|
|
@ -173,6 +221,12 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.discardRuntimeGitPath(params.worktree, params.filePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.bulkDiscard',
|
||||
params: GitBulkPaths,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.bulkDiscardRuntimeGitPaths(params.worktree, params.filePaths)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.remoteFileUrl',
|
||||
params: GitRemoteFileUrl,
|
||||
|
|
|
|||
|
|
@ -694,6 +694,40 @@ describe('OrcaRuntimeRpcServer', () => {
|
|||
expect(pushRuntimeGit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
||||
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
||||
const channelDevice = server['deviceRegistry']!.addDevice('phone', 'mobile')
|
||||
const requestDevice = server['deviceRegistry']!.addDevice('cli', 'runtime')
|
||||
const replies: Record<string, unknown>[] = []
|
||||
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_mismatch',
|
||||
method: 'status.get',
|
||||
deviceToken: requestDevice.token
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {},
|
||||
undefined,
|
||||
undefined,
|
||||
channelDevice.token
|
||||
)
|
||||
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'req_mismatch',
|
||||
ok: false,
|
||||
error: expect.objectContaining({ code: 'unauthorized' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
|
|
|||
|
|
@ -459,12 +459,14 @@ export class OrcaRuntimeRpcServer {
|
|||
}
|
||||
})
|
||||
channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => {
|
||||
const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null
|
||||
void this.handleWebSocketMessage(
|
||||
plaintext,
|
||||
encryptedReply,
|
||||
encryptedBinaryReply,
|
||||
wsTransport,
|
||||
ws
|
||||
ws,
|
||||
authenticatedDeviceToken
|
||||
)
|
||||
})
|
||||
channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws))
|
||||
|
|
@ -632,7 +634,8 @@ export class OrcaRuntimeRpcServer {
|
|||
reply: (response: string) => void,
|
||||
sendBinary: (response: Uint8Array<ArrayBufferLike>) => void,
|
||||
wsTransport?: WebSocketTransport,
|
||||
ws?: WebSocket
|
||||
ws?: WebSocket,
|
||||
authenticatedDeviceToken?: string | null
|
||||
): Promise<void> {
|
||||
let request: RpcRequest
|
||||
try {
|
||||
|
|
@ -651,10 +654,17 @@ export class OrcaRuntimeRpcServer {
|
|||
return
|
||||
}
|
||||
|
||||
const token =
|
||||
const requestToken =
|
||||
typeof (request as Record<string, unknown>).deviceToken === 'string'
|
||||
? ((request as Record<string, unknown>).deviceToken as string)
|
||||
: null
|
||||
if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch')))
|
||||
return
|
||||
}
|
||||
// Why: E2EE already authenticated the WebSocket channel. Use that bound
|
||||
// identity for authorization instead of trusting a repeated request field.
|
||||
const token = authenticatedDeviceToken ?? requestToken
|
||||
if (!token) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token')))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 180_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2',
|
||||
archiveSha256: '5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
|
||||
sampleRate: 16000,
|
||||
|
|
@ -28,6 +29,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 170_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2',
|
||||
archiveSha256: '157c157bc51155e03e37d2466522a3a737dd9c72bb25f36eb18912964161e1ad',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
|
||||
sampleRate: 16000,
|
||||
|
|
@ -43,6 +45,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 130_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2',
|
||||
archiveSha256: '27ffbd9ee24ad186d99acc2f6354d7992b27bcab490812510665fa8f9389c5f8',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: [
|
||||
'encoder-epoch-99-avg-1.onnx',
|
||||
|
|
@ -64,6 +67,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 115_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2',
|
||||
archiveSha256: '5462a1fce42693deae572af1e8c4687124b12aa85fe61ff4d3168bb5280e205f',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'tokens.txt'],
|
||||
sampleRate: 16000,
|
||||
|
|
@ -78,6 +82,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 128_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2',
|
||||
archiveSha256: '9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: [
|
||||
'encoder-epoch-99-avg-1.onnx',
|
||||
|
|
@ -98,6 +103,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 74_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2',
|
||||
archiveSha256: '2cbd71b640d9c37d3784f29367333a4577b0398b62e9deeed418170b081cba8b',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: [
|
||||
'encoder-epoch-99-avg-1.onnx',
|
||||
|
|
@ -118,6 +124,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
|
|||
sizeBytes: 116_000_000,
|
||||
downloadUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2',
|
||||
archiveSha256: 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1',
|
||||
archiveFormat: 'tar.bz2',
|
||||
files: ['tiny-encoder.onnx', 'tiny-decoder.onnx', 'tiny-tokens.txt'],
|
||||
sampleRate: 16000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { createHash } from 'crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SPEECH_MODEL_CATALOG } from './model-catalog'
|
||||
import { ModelManager } from './model-manager'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-speech-models-test'
|
||||
}
|
||||
}))
|
||||
|
||||
type ModelManagerInternals = {
|
||||
verifyArchiveSha256: (archivePath: string, expectedSha256: string) => Promise<void>
|
||||
downloadFile: (
|
||||
url: string,
|
||||
dest: string,
|
||||
expectedSize: number,
|
||||
modelId: string,
|
||||
isAborted: () => boolean
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
describe('ModelManager', () => {
|
||||
it('requires pinned SHA-256 hashes for every catalog archive', () => {
|
||||
for (const manifest of SPEECH_MODEL_CATALOG) {
|
||||
expect(manifest.archiveSha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies downloaded archive hashes before extraction', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
|
||||
try {
|
||||
const archivePath = join(dir, 'model.tar.bz2')
|
||||
writeFileSync(archivePath, 'known archive bytes')
|
||||
const expected = createHash('sha256').update('known archive bytes').digest('hex')
|
||||
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
|
||||
|
||||
await expect(manager.verifyArchiveSha256(archivePath, expected)).resolves.toBeUndefined()
|
||||
await expect(manager.verifyArchiveSha256(archivePath, '0'.repeat(64))).rejects.toThrow(
|
||||
/integrity verification/
|
||||
)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-HTTPS model downloads', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
|
||||
try {
|
||||
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
|
||||
|
||||
await expect(
|
||||
manager.downloadFile(
|
||||
'http://example.com/model.tar.bz2',
|
||||
join(dir, 'model.tar.bz2'),
|
||||
1,
|
||||
'm',
|
||||
() => false
|
||||
)
|
||||
).rejects.toThrow(/HTTPS/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
|
||||
import { app } from 'electron'
|
||||
import { join, resolve, relative } from 'path'
|
||||
import { existsSync, mkdirSync, createWriteStream, rmSync } from 'fs'
|
||||
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'fs'
|
||||
import { readdir, rm } from 'fs/promises'
|
||||
import { createHash } from 'crypto'
|
||||
import { get as httpsGet } from 'https'
|
||||
import { get as httpGet, type IncomingMessage } from 'http'
|
||||
import type { IncomingMessage } from 'http'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import { spawn } from 'child_process'
|
||||
import type {
|
||||
|
|
@ -12,6 +14,7 @@ import type {
|
|||
SpeechModelStatus
|
||||
} from '../../shared/speech-types'
|
||||
import { SPEECH_MODEL_CATALOG, getCatalogModel } from './model-catalog'
|
||||
import { resolveTarExecutable } from './tar-executable'
|
||||
|
||||
type DownloadHandle = {
|
||||
abort: () => void
|
||||
|
|
@ -132,6 +135,13 @@ export class ModelManager {
|
|||
return
|
||||
}
|
||||
|
||||
await this.verifyArchiveSha256(archivePath, manifest.archiveSha256)
|
||||
|
||||
if (aborted) {
|
||||
this.cleanup(modelId, archivePath)
|
||||
return
|
||||
}
|
||||
|
||||
this.updateState(modelId, 'extracting')
|
||||
await this.extractArchive(archivePath, this.modelsDir, modelId, () => aborted)
|
||||
|
||||
|
|
@ -212,25 +222,71 @@ export class ModelManager {
|
|||
dest: string,
|
||||
expectedSize: number,
|
||||
modelId: string,
|
||||
isAborted: () => boolean
|
||||
isAborted: () => boolean,
|
||||
redirectCount = 0
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const getter = url.startsWith('https') ? httpsGet : httpGet
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
reject(new Error('Invalid download URL'))
|
||||
return
|
||||
}
|
||||
|
||||
const request = getter(url, (response: IncomingMessage) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
reject(new Error('Model downloads must use HTTPS'))
|
||||
return
|
||||
}
|
||||
|
||||
const request = httpsGet(parsedUrl, (response: IncomingMessage) => {
|
||||
if (
|
||||
response.statusCode === 301 ||
|
||||
response.statusCode === 302 ||
|
||||
response.statusCode === 303 ||
|
||||
response.statusCode === 307 ||
|
||||
response.statusCode === 308
|
||||
) {
|
||||
const redirectUrl = response.headers.location
|
||||
if (!redirectUrl) {
|
||||
response.resume()
|
||||
reject(new Error('Redirect without location'))
|
||||
return
|
||||
}
|
||||
this.downloadFile(redirectUrl, dest, expectedSize, modelId, isAborted)
|
||||
if (redirectCount >= 5) {
|
||||
response.resume()
|
||||
reject(new Error('Too many redirects'))
|
||||
return
|
||||
}
|
||||
let resolvedRedirect: URL
|
||||
try {
|
||||
resolvedRedirect = new URL(redirectUrl, parsedUrl)
|
||||
} catch {
|
||||
response.resume()
|
||||
reject(new Error('Invalid redirect URL'))
|
||||
return
|
||||
}
|
||||
if (resolvedRedirect.protocol !== 'https:') {
|
||||
response.resume()
|
||||
reject(new Error('Model download redirect must use HTTPS'))
|
||||
return
|
||||
}
|
||||
response.resume()
|
||||
this.downloadFile(
|
||||
resolvedRedirect.toString(),
|
||||
dest,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
redirectCount + 1
|
||||
)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume()
|
||||
reject(new Error(`HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
|
@ -266,6 +322,26 @@ export class ModelManager {
|
|||
})
|
||||
}
|
||||
|
||||
private verifyArchiveSha256(archivePath: string, expectedSha256: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(archivePath)
|
||||
|
||||
stream.on('data', (chunk) => hash.update(chunk))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => {
|
||||
const actualSha256 = hash.digest('hex')
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
// Why: these archives feed native model parsers; filename checks do
|
||||
// not protect against compromised or redirected release assets.
|
||||
reject(new Error('Downloaded model archive failed integrity verification'))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
|
|
@ -280,9 +356,15 @@ export class ModelManager {
|
|||
// (1MB default maxBuffer). bzip2 decompression is slow (~1-5 min for
|
||||
// 170MB archives) and exec can silently kill the process if stderr
|
||||
// exceeds the buffer. spawn streams output without buffering.
|
||||
const child = spawn('tar', ['-xjf', archivePath, '-C', modelDir, '--strip-components=1'], {
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
const tarExecutable = resolveTarExecutable()
|
||||
const child = spawn(
|
||||
tarExecutable,
|
||||
['-xjf', archivePath, '-C', modelDir, '--strip-components=1'],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { existsSync } from 'fs'
|
||||
import { win32 as pathWin32 } from 'path'
|
||||
|
||||
export function resolveTarExecutable(
|
||||
options: {
|
||||
platform?: NodeJS.Platform
|
||||
env?: NodeJS.ProcessEnv
|
||||
exists?: (path: string) => boolean
|
||||
} = {}
|
||||
): string {
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform !== 'win32') {
|
||||
return 'tar'
|
||||
}
|
||||
|
||||
const env = options.env ?? process.env
|
||||
const systemRoot = env.SystemRoot ?? env.WINDIR ?? 'C:\\Windows'
|
||||
const candidate = pathWin32.join(systemRoot, 'System32', 'tar.exe')
|
||||
const exists = options.exists ?? existsSync
|
||||
if (exists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
// Why: packaged Windows apps can have a stripped PATH. Use the OS tar
|
||||
// location explicitly, and fail with a repairable error if it is absent.
|
||||
throw new Error(`Windows tar.exe not found at ${candidate}`)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
|
||||
export type CommitMessageAgentEnvironmentResolvers = {
|
||||
prepareForCodexLaunch?: () => string | null
|
||||
prepareForClaudeLaunch?: () => Promise<ClaudeRuntimeAuthPreparation>
|
||||
}
|
||||
|
||||
function cloneProcessEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
export async function prepareLocalCommitMessageAgentEnv(
|
||||
agentId: string,
|
||||
resolvers: CommitMessageAgentEnvironmentResolvers | undefined
|
||||
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
|
||||
if (!resolvers) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
try {
|
||||
if (agentId === 'codex' && resolvers.prepareForCodexLaunch) {
|
||||
const codexHomePath = resolvers.prepareForCodexLaunch()
|
||||
return {
|
||||
ok: true,
|
||||
env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) {
|
||||
const preparation = await resolvers.prepareForClaudeLaunch()
|
||||
const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, {
|
||||
stripAuthEnv: preparation.stripAuthEnv
|
||||
})
|
||||
return { ok: true, env }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[commit-message] Failed to prepare agent environment:', error)
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Failed to prepare the selected agent account for commit message generation.'
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
|
@ -85,14 +85,23 @@ export function createRichMarkdownExtensions({
|
|||
| RuntimeFileOperationArgs
|
||||
| undefined
|
||||
if (src && fp) {
|
||||
// Why: when IPC resolution fails (e.g. unsupported format),
|
||||
// the ternary falls back to the raw src so the browser can
|
||||
// attempt its own loading rather than leaving a broken image.
|
||||
void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => {
|
||||
img.src = resolved ? resolved : src
|
||||
if (currentSrc !== src) {
|
||||
return
|
||||
}
|
||||
if (resolved) {
|
||||
img.src = resolved
|
||||
return
|
||||
}
|
||||
// Why: local image paths must stay behind IPC/runtime
|
||||
// authorization; a failed load should render missing, not
|
||||
// hand the raw path back to Chromium.
|
||||
img.removeAttribute('src')
|
||||
})
|
||||
} else if (src) {
|
||||
img.src = src
|
||||
} else {
|
||||
img.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalImageCacheKey } from './useLocalImageSrc'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLocalImageCacheKey, loadLocalImageSrc } from './useLocalImageSrc'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('getLocalImageCacheKey', () => {
|
||||
it('scopes local markdown image cache entries by runtime owner', () => {
|
||||
|
|
@ -22,4 +26,35 @@ describe('getLocalImageCacheKey', () => {
|
|||
expect(localKey).not.toBe(remoteKey)
|
||||
expect(remoteKey).not.toBe(otherRemoteKey)
|
||||
})
|
||||
|
||||
it('does not fall back to raw local src when IPC returns non-binary content', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue({
|
||||
isBinary: false,
|
||||
content: '<svg></svg>',
|
||||
mimeType: 'image/svg+xml'
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
fs: { readFile }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(loadLocalImageSrc('diagram.svg', '/repo/docs/readme.md')).resolves.toBeNull()
|
||||
expect(readFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/docs/diagram.svg',
|
||||
connectionId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fall back to raw local src when IPC rejects the read', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
fs: { readFile: vi.fn().mockRejectedValue(new Error('denied')) }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadLocalImageSrc('file:///repo/docs/diagram.png', '/repo/docs/readme.md')
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export function useLocalImageSrc(
|
|||
|
||||
const absolutePath = resolveImageAbsolutePath(rawSrc, filePath)
|
||||
if (!absolutePath) {
|
||||
setDisplaySrc(rawSrc)
|
||||
setDisplaySrc(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -174,15 +174,14 @@ export function useLocalImageSrc(
|
|||
cacheBlobUrl(cacheKey, url)
|
||||
setDisplaySrc(url)
|
||||
} else {
|
||||
// Why: if the file exists but is not binary (e.g. an SVG stored as
|
||||
// text) or content is empty, fall back to the raw src so the browser
|
||||
// can attempt its own loading rather than leaving a broken image.
|
||||
setDisplaySrc(rawSrc)
|
||||
// Why: local image paths must stay behind IPC/runtime authorization;
|
||||
// handing raw file: or relative paths back to Chromium can escape it.
|
||||
setDisplaySrc(undefined)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDisplaySrc(rawSrc)
|
||||
setDisplaySrc(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -232,10 +231,9 @@ export async function loadLocalImageSrc(
|
|||
cacheBlobUrl(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
// Why: if the file is not binary (e.g. an SVG stored as text) or content
|
||||
// is empty, return the raw src so the caller can still display something
|
||||
// rather than treating it as a permanent failure.
|
||||
return rawSrc
|
||||
// Why: local image paths must stay behind IPC/runtime authorization;
|
||||
// callers should render a missing image instead of falling back to raw src.
|
||||
return null
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,10 +108,13 @@ import {
|
|||
} from '@/components/editor/editor-autosave'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import {
|
||||
bulkDiscardRuntimeGitPaths,
|
||||
bulkStageRuntimeGitPaths,
|
||||
bulkUnstageRuntimeGitPaths,
|
||||
cancelRuntimeGenerateCommitMessage,
|
||||
commitRuntimeGit,
|
||||
discardRuntimeGitPath,
|
||||
generateRuntimeCommitMessage,
|
||||
getRuntimeGitBranchCompare,
|
||||
stageRuntimeGitPath,
|
||||
unstageRuntimeGitPath
|
||||
|
|
@ -1027,12 +1030,12 @@ function SourceControlInner(): React.JSX.Element {
|
|||
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
|
||||
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
|
||||
try {
|
||||
const result = (await window.api.git.generateCommitMessage({
|
||||
const result = await generateRuntimeCommitMessage({
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})) as
|
||||
| { success: true; message: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
// Why: cancellation is a deliberate user action, not a failure to
|
||||
|
|
@ -1082,7 +1085,12 @@ function SourceControlInner(): React.JSX.Element {
|
|||
// Why: fire-and-forget — the in-flight generateCommitMessage promise
|
||||
// resolves with `{canceled: true}` once the kill propagates, which is
|
||||
// where the spinner is cleared. Awaiting here would just delay UI feedback.
|
||||
void window.api.git.cancelGenerateCommitMessage({ worktreePath, connectionId })
|
||||
void cancelRuntimeGenerateCommitMessage({
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})
|
||||
}, [activeWorktreeId, worktreePath])
|
||||
|
||||
// Why: a single dispatcher for every remote-only action the split button or
|
||||
|
|
@ -2071,7 +2079,15 @@ function SourceControlInner(): React.JSX.Element {
|
|||
)
|
||||
)
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
await window.api.git.bulkDiscard({ worktreePath, filePaths, connectionId })
|
||||
await bulkDiscardRuntimeGitPaths(
|
||||
{
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
filePaths
|
||||
)
|
||||
for (const relativePath of filePaths) {
|
||||
notifyEditorExternalFileChange({
|
||||
worktreeId: activeWorktreeId,
|
||||
|
|
|
|||
|
|
@ -721,6 +721,11 @@ export function ResourceUsageStatusSegment({
|
|||
// Why: Space scans can finish after the user backs out of the full page or
|
||||
// closes this popover; the status-bar trigger becomes the handoff point.
|
||||
useEffect(() => {
|
||||
if (runtimeEnvironmentActive) {
|
||||
setSpaceScanReady(false)
|
||||
previousSpaceScanningRef.current = false
|
||||
return
|
||||
}
|
||||
const scannedAt = workspaceSpaceScannedAt
|
||||
const wasScanning = previousSpaceScanningRef.current
|
||||
const scanCompleted =
|
||||
|
|
@ -738,7 +743,14 @@ export function ResourceUsageStatusSegment({
|
|||
}
|
||||
|
||||
previousSpaceScanningRef.current = workspaceSpaceScanning
|
||||
}, [activeView, open, spaceScanReady, workspaceSpaceScannedAt, workspaceSpaceScanning])
|
||||
}, [
|
||||
activeView,
|
||||
open,
|
||||
runtimeEnvironmentActive,
|
||||
spaceScanReady,
|
||||
workspaceSpaceScannedAt,
|
||||
workspaceSpaceScanning
|
||||
])
|
||||
|
||||
// Poll memory + sessions when popover is open. Sessions also poll in the
|
||||
// background at a slower rate so the badge count stays reasonably fresh
|
||||
|
|
@ -976,9 +988,12 @@ export function ResourceUsageStatusSegment({
|
|||
}, [])
|
||||
|
||||
const handleOpenWorkspaceCleanup = useCallback((): void => {
|
||||
if (runtimeEnvironmentActive) {
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
queueMicrotask(() => openModal('workspace-cleanup'))
|
||||
}, [openModal])
|
||||
}, [openModal, runtimeEnvironmentActive])
|
||||
|
||||
const handleKillSession = useCallback(
|
||||
(session: UnifiedSessionRow): void => {
|
||||
|
|
@ -1072,10 +1087,12 @@ export function ResourceUsageStatusSegment({
|
|||
type="button"
|
||||
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
|
||||
aria-label={
|
||||
spaceScanReady ? 'Resource manager, Space scan ready' : 'Resource manager'
|
||||
spaceScanReady && !runtimeEnvironmentActive
|
||||
? 'Resource manager, Space scan ready'
|
||||
: 'Resource manager'
|
||||
}
|
||||
>
|
||||
{spaceScanReady ? (
|
||||
{spaceScanReady && !runtimeEnvironmentActive ? (
|
||||
<span
|
||||
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
|
|
@ -1114,7 +1131,9 @@ export function ResourceUsageStatusSegment({
|
|||
Resource Manager — {memBadgeLabel} · {sessions.length} session
|
||||
{sessions.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
{spaceScanReady ? <div className="text-primary">Space scan ready</div> : null}
|
||||
{spaceScanReady && !runtimeEnvironmentActive ? (
|
||||
<div className="text-primary">Space scan ready</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -1361,32 +1380,38 @@ export function ResourceUsageStatusSegment({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 py-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorkspaceCleanup}
|
||||
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 truncate px-4 text-center">
|
||||
delete inactive workspaces ({oldWorkspaceCount})
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="absolute right-2.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{orphanCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleKillOrphans()}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
Kill {orphanCount} orphan terminal{orphanCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{!runtimeEnvironmentActive || orphanCount > 0 ? (
|
||||
<div className="border-t border-border/50 px-3 py-2 shrink-0">
|
||||
{!runtimeEnvironmentActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorkspaceCleanup}
|
||||
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 truncate px-4 text-center">
|
||||
delete inactive workspaces ({oldWorkspaceCount})
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="absolute right-2.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
{orphanCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleKillOrphans()}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
Kill {orphanCount} orphan terminal{orphanCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
|
||||
{!runtimeEnvironmentActive ? (
|
||||
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
{/* Why: Radix Dialog must not be a descendant of PopoverContent — when
|
||||
the popover unmounts (e.g. clicking outside, focus moving to the
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bulkDiscardRuntimeGitPaths,
|
||||
bulkStageRuntimeGitPaths,
|
||||
cancelRuntimeGenerateCommitMessage,
|
||||
commitRuntimeGit,
|
||||
generateRuntimeCommitMessage,
|
||||
getRuntimeGitDiff,
|
||||
getRuntimeGitStatus,
|
||||
pushRuntimeGit
|
||||
|
|
@ -15,8 +18,11 @@ import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
|||
const gitStatus = vi.fn()
|
||||
const gitDiff = vi.fn()
|
||||
const gitBulkStage = vi.fn()
|
||||
const gitBulkDiscard = vi.fn()
|
||||
const gitCommit = vi.fn()
|
||||
const gitPush = vi.fn()
|
||||
const gitGenerateCommitMessage = vi.fn()
|
||||
const gitCancelGenerateCommitMessage = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
const runtimeCall = vi.fn()
|
||||
|
|
@ -26,8 +32,11 @@ beforeEach(() => {
|
|||
gitStatus.mockReset()
|
||||
gitDiff.mockReset()
|
||||
gitBulkStage.mockReset()
|
||||
gitBulkDiscard.mockReset()
|
||||
gitCommit.mockReset()
|
||||
gitPush.mockReset()
|
||||
gitGenerateCommitMessage.mockReset()
|
||||
gitCancelGenerateCommitMessage.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeCall.mockReset()
|
||||
|
|
@ -40,8 +49,11 @@ beforeEach(() => {
|
|||
status: gitStatus,
|
||||
diff: gitDiff,
|
||||
bulkStage: gitBulkStage,
|
||||
bulkDiscard: gitBulkDiscard,
|
||||
commit: gitCommit,
|
||||
push: gitPush
|
||||
push: gitPush,
|
||||
generateCommitMessage: gitGenerateCommitMessage,
|
||||
cancelGenerateCommitMessage: gitCancelGenerateCommitMessage
|
||||
},
|
||||
runtime: { call: runtimeCall },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
|
|
@ -161,7 +173,7 @@ describe('runtime git client', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('routes bulk stage and remote operations through the active runtime', async () => {
|
||||
it('routes bulk mutations and remote operations through the active runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
|
|
@ -175,7 +187,10 @@ describe('runtime git client', () => {
|
|||
}
|
||||
|
||||
await bulkStageRuntimeGitPaths(context, ['a.ts', 'b.ts'])
|
||||
await bulkDiscardRuntimeGitPaths(context, ['c.ts', 'd.ts'])
|
||||
await commitRuntimeGit(context, 'feat: test')
|
||||
await generateRuntimeCommitMessage(context)
|
||||
await cancelRuntimeGenerateCommitMessage(context)
|
||||
await pushRuntimeGit(context, { publish: true, pushTarget: { remote: 'origin' } as never })
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
|
||||
|
|
@ -185,16 +200,75 @@ describe('runtime git client', () => {
|
|||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: 'env-1',
|
||||
method: 'git.bulkDiscard',
|
||||
params: { worktree: 'wt-1', filePaths: ['c.ts', 'd.ts'] },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
|
||||
selector: 'env-1',
|
||||
method: 'git.commit',
|
||||
params: { worktree: 'wt-1', message: 'feat: test' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
|
||||
selector: 'env-1',
|
||||
method: 'git.generateCommitMessage',
|
||||
params: { worktree: 'wt-1' },
|
||||
timeoutMs: 75_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
|
||||
selector: 'env-1',
|
||||
method: 'git.cancelGenerateCommitMessage',
|
||||
params: { worktree: 'wt-1' },
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
|
||||
selector: 'env-1',
|
||||
method: 'git.push',
|
||||
params: { worktree: 'wt-1', publish: true, pushTarget: { remote: 'origin' } },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('passes commit-message settings to the active runtime', async () => {
|
||||
const commitMessageAi = {
|
||||
enabled: true,
|
||||
agentId: 'codex' as const,
|
||||
selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' },
|
||||
selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' },
|
||||
customPrompt: 'Prefer concise subjects.',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
const agentCmdOverrides = { codex: 'codex --profile work' }
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { success: true, message: 'feat: test' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
await generateRuntimeCommitMessage({
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
},
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.generateCommitMessage',
|
||||
params: {
|
||||
worktree: 'wt-1',
|
||||
commitMessageAi,
|
||||
agentCmdOverrides,
|
||||
enableGitHubAttribution: true
|
||||
},
|
||||
timeoutMs: 75_000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,13 +12,41 @@ import type {
|
|||
} from '../../../shared/types'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
|
||||
export type RuntimeGenerateCommitMessageResult =
|
||||
| { success: true; message: string; agentLabel?: string }
|
||||
| { success: false; error: string; canceled?: boolean }
|
||||
|
||||
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
|
||||
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
|
||||
|
||||
export type RuntimeGitContext = {
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
settings: RuntimeGitSettings | null | undefined
|
||||
worktreeId: string | null | undefined
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
function getRuntimeCommitMessageSettings(
|
||||
settings: RuntimeGitSettings | null | undefined
|
||||
): Partial<
|
||||
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
|
||||
> {
|
||||
if (!settings) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
...(settings.commitMessageAi !== undefined
|
||||
? { commitMessageAi: settings.commitMessageAi }
|
||||
: {}),
|
||||
...(settings.agentCmdOverrides !== undefined
|
||||
? { agentCmdOverrides: settings.agentCmdOverrides }
|
||||
: {}),
|
||||
...(settings.enableGitHubAttribution !== undefined
|
||||
? { enableGitHubAttribution: settings.enableGitHubAttribution }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeGitScope(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
connectionId: string | undefined
|
||||
|
|
@ -218,6 +246,46 @@ export async function commitRuntimeGit(
|
|||
)
|
||||
}
|
||||
|
||||
export async function generateRuntimeCommitMessage(
|
||||
context: RuntimeGitContext
|
||||
): Promise<RuntimeGenerateCommitMessageResult> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
return window.api.git.generateCommitMessage({
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId
|
||||
}) as Promise<RuntimeGenerateCommitMessageResult>
|
||||
}
|
||||
return callRuntimeRpc<RuntimeGenerateCommitMessageResult>(
|
||||
target,
|
||||
'git.generateCommitMessage',
|
||||
{
|
||||
worktree: context.worktreeId,
|
||||
...getRuntimeCommitMessageSettings(context.settings)
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function cancelRuntimeGenerateCommitMessage(
|
||||
context: RuntimeGitContext
|
||||
): Promise<void> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
await window.api.git.cancelGenerateCommitMessage({
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId
|
||||
})
|
||||
return
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'git.cancelGenerateCommitMessage',
|
||||
{ worktree: context.worktreeId },
|
||||
{ timeoutMs: 5_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function stageRuntimeGitPath(
|
||||
context: RuntimeGitContext,
|
||||
filePath: string
|
||||
|
|
@ -302,6 +370,27 @@ export async function bulkUnstageRuntimeGitPaths(
|
|||
)
|
||||
}
|
||||
|
||||
export async function bulkDiscardRuntimeGitPaths(
|
||||
context: RuntimeGitContext,
|
||||
filePaths: string[]
|
||||
): Promise<void> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
await window.api.git.bulkDiscard({
|
||||
worktreePath: context.worktreePath,
|
||||
filePaths,
|
||||
connectionId: context.connectionId
|
||||
})
|
||||
return
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'git.bulkDiscard',
|
||||
{ worktree: context.worktreeId, filePaths },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function discardRuntimeGitPath(
|
||||
context: RuntimeGitContext,
|
||||
filePath: string
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type SpeechModelManifest = {
|
|||
language: string
|
||||
sizeBytes: number
|
||||
downloadUrl: string
|
||||
archiveSha256: string
|
||||
archiveFormat: 'tar.bz2'
|
||||
files: string[]
|
||||
sampleRate: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue