Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSet
})
sessionGoalService.loadOpenGoals()

const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces)
const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService)
const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient)

sseAggregator.onEvent((directory, event) => {
Expand Down
35 changes: 35 additions & 0 deletions backend/src/services/multi-run-fusion.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { resolve } from 'node:path'
import {
isSessionNotFoundError,
type FileDiffInfo,
type SessionInfo,
} from '@opencode-manager/shared/opencode'
import {
FUSION_PROMPT_MAX_LENGTH,
buildSchedulePermissionRuleset,
type FusionUnavailableSource,
type MultiRunFusionSource,
type SchedulePermissionRuleset,
} from '@opencode-manager/shared/schemas'
import type { MultiRunEntryRecord } from '../db/multi-runs'
import { getErrorMessage } from '../utils/error-utils'
Expand Down Expand Up @@ -42,6 +45,38 @@ const FUSION_REPLY_BUDGET_RATIO = 0.4

const FUSION_BLOCK_SEPARATOR = '\n\n'

/**
* Builds the OpenCode session permission ruleset that lets a fusion session read
* its selected source workspaces while denying any edit to them.
*
* The ruleset starts from the default unattended baseline
* (`buildSchedulePermissionRuleset(null)`), which denies external directories,
* questions, and destructive shell patterns. Because rules are last-match-wins,
* the per-source rules appended afterwards take precedence for those paths.
*
* Each unique directory is normalized with `resolve` before a pair of rules is
* emitted: an `external_directory` allow so the session may read outside its own
* workspace, followed by an `edit` deny so the read-only reference cannot be
* mutated. Deduplication happens after normalization.
*/
export function buildFusionSourcePermissionRuleset(directories: string[]): SchedulePermissionRuleset {
const seen = new Set<string>()
const ruleset = buildSchedulePermissionRuleset(null)

for (const directory of directories) {
const normalized = resolve(directory).replaceAll('\\', '/')
if (seen.has(normalized)) {
continue
}

seen.add(normalized)
ruleset.push({ action: 'external_directory', resource: `${normalized}/*`, effect: 'allow' })
ruleset.push({ action: 'edit', resource: `${normalized}/*`, effect: 'deny' })
}

return ruleset
}

export class FusionContextLimitError extends ServiceError {
constructor(requiredPerSource: number, availablePerSource: number) {
super(
Expand Down
56 changes: 32 additions & 24 deletions backend/src/services/multi-runs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Database } from 'bun:sqlite'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas'
import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils'
import {
createMultiRunWithEntries,
getMultiRun,
Expand All @@ -18,12 +18,14 @@ import {
import { getRepoById } from '../db/queries'
import type { Repo } from '../types/repo'
import { getErrorMessage } from '../utils/error-utils'
import { logger } from '../utils/logger'
import { ServiceError } from '../utils/service-error'
import { buildFusionPrompt, collectFusionSources } from './multi-run-fusion'
import { buildFusionPrompt, buildFusionSourcePermissionRuleset, collectFusionSources } from './multi-run-fusion'
import type { OpenCodeClient } from './opencode/client'
import { RepoWorkspaceError } from './repo'
import type { RepoWorkspaceService } from './repo-workspace'
import { requireReadyRepo, SessionLauncher, SessionLaunchError, type LaunchedSession } from './session-launcher'
import type { SessionPermissionModeService } from './session-permission-modes'

const MULTI_RUN_LIST_LIMIT = 20

Expand Down Expand Up @@ -75,6 +77,7 @@ export class MultiRunService {
private readonly db: Database,
private readonly openCodeClient: OpenCodeClient,
private readonly repoWorkspaces: RepoWorkspaceService,
private readonly permissionModes: SessionPermissionModeService,
) {
this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces)
}
Expand Down Expand Up @@ -165,6 +168,12 @@ export class MultiRunService {
return { run: this.reload(multiRunId), created: false }
}

if (record.repoId === ASSISTANT_REPO_ID) {
throw new MultiRunError('Fusion needs a Git repository because it always runs in a new worktree.', 409, {
code: 'FUSION_REQUIRES_GIT_REPOSITORY',
})
}

let repo: Repo
try {
repo = requireReadyRepo(this.db, record.repoId)
Expand All @@ -181,18 +190,11 @@ export class MultiRunService {
return entry
})

if (!request.isolate) {
const overlappingEntryIds = selectedEntries
.filter((entry) => entry.directory !== null && resolve(entry.directory) === resolve(repo.fullPath))
.map((entry) => entry.id)
if (overlappingEntryIds.length > 0) {
throw new MultiRunError(
'The synthesis cannot run in the repository checkout because a selected result ran there. Enable an isolated workspace.',
409,
{ code: 'FUSION_DESTINATION_OVERLAPS_SOURCE', details: { entryIds: overlappingEntryIds } },
)
}
}
const sourcePermissions = buildFusionSourcePermissionRuleset(
selectedEntries
.map((entry) => entry.directory)
.filter((directory): directory is string => directory !== null),
)

try {
await this.sessionLauncher.resolveModel(repo, request.model)
Expand Down Expand Up @@ -225,7 +227,7 @@ export class MultiRunService {
requestId: request.requestId,
model: request.model,
instructions: request.instructions ?? null,
isolated: request.isolate,
isolated: true,
baseRef: request.baseRef ?? null,
sources: built.sources,
})
Expand All @@ -234,21 +236,19 @@ export class MultiRunService {
return { run: this.reload(multiRunId), created: false }
}

let launched: LaunchedSession | null = null
try {
const launched = await this.sessionLauncher.launch({
launched = await this.sessionLauncher.launch({
repoId: record.repoId,
prompt: built.prompt,
model: request.model,
title: `${record.name} · fusion`,
...(request.agent ? { agent: request.agent } : {}),
...(request.isolate
? {
workspace: {
name: `${record.name}-fusion-${fusion.id}`,
...(request.baseRef ? { ref: request.baseRef } : {}),
},
}
: {}),
permissions: sourcePermissions,
workspace: {
name: `${record.name}-fusion-${fusion.id}`,
...(request.baseRef ? { ref: request.baseRef } : {}),
},
})

updateMultiRunFusion(this.db, fusion.id, ['starting'], {
Expand All @@ -267,6 +267,14 @@ export class MultiRunService {
})
}

if (launched) {
try {
await this.permissionModes.applyDefaultMode(launched.sessionId, launched.directory)
} catch (error) {
logger.error(`Failed to apply the default permission mode to fusion session ${launched.sessionId}:`, error)
}
}

return { run: this.reload(multiRunId), created: true }
}

Expand Down
4 changes: 4 additions & 0 deletions backend/src/services/session-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import type { OpenCodeClient } from './opencode/client'
import { resolveOpenCodeModel, type ResolvedOpenCodeModel } from './opencode-models'
import type { RepoWorkspaceService } from './repo-workspace'

type SessionCreateInput = NonNullable<Parameters<OpenCodeClient['api']['session']['create']>[0]>

interface LaunchSessionInput {
repoId: number
prompt: string
title?: string
model?: string
agent?: string
workspace?: { name?: string; ref?: string }
permissions?: SessionCreateInput['permissions']
}

export interface LaunchedSession {
Expand Down Expand Up @@ -109,6 +112,7 @@ export class SessionLauncher {
session = await this.openCodeClient.api.session.create({
...(input.title ? { title: input.title } : {}),
...(input.agent ? { agent: input.agent } : {}),
...(input.permissions ? { permissions: input.permissions } : {}),
model: {
providerID: model.providerID,
id: model.id,
Expand Down
15 changes: 15 additions & 0 deletions backend/src/services/session-permission-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ export class SessionPermissionModeService {
return this.settingsService.getSettings().preferences.sessionDefaults?.permissionMode ?? 'ask'
}

/**
* Records the default permission mode for a session after it has been launched
* and accepts any permission requests it raised before the mode was stored.
*
* Does nothing when the default mode is `ask`, since that needs no stored row.
*/
async applyDefaultMode(sessionId: string, directory: string): Promise<void> {
if (this.defaultMode() !== 'auto') {
return
}

insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto')
await this.acceptPendingRequestsInDirectory(directory, sessionId)
}

async acceptPendingRequestsForActiveSessions(): Promise<void> {
try {
const active = await this.openCodeClient.api.session.active()
Expand Down
1 change: 0 additions & 1 deletion backend/test/db/multi-run-fusions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ describe('FuseMultiRunRequestSchema', () => {
requestId: '00000000-0000-4000-8000-000000000000',
entryIds: [1, 2],
model: 'openai/gpt-5',
isolate: true,
}

it('accepts at least two unique entry ids', () => {
Expand Down
2 changes: 0 additions & 2 deletions backend/test/routes/multi-runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ function fuseBody(overrides: Record<string, unknown> = {}): string {
requestId: '11111111-1111-4111-8111-111111111111',
entryIds: [1, 2],
model: 'openai/a',
isolate: true,
...overrides,
})
}
Expand Down Expand Up @@ -175,7 +174,6 @@ describe('multi-run routes', () => {
requestId: '11111111-1111-4111-8111-111111111111',
entryIds: [1, 2],
model: 'openai/a',
isolate: true,
})
})

Expand Down
Loading