mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(runtime): refuse headless tab split/move into an unknown group (#27381)
The headless split and move-to-group handlers answered {moved:true} when the
target group did not exist, hiding desktop/server group-id drift (#26522).
They now refuse with target_group_not_found, as reorder already does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import type {
|
||||
RuntimeMobileSessionTabMove,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
|
||||
function terminalTab(parentTabId: string, isActive: boolean) {
|
||||
return {
|
||||
type: 'terminal' as const,
|
||||
id: `${parentTabId}::leaf-1`,
|
||||
parentTabId,
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal',
|
||||
isActive
|
||||
}
|
||||
}
|
||||
|
||||
// No notifier, so moves take the headless (serve) path.
|
||||
function headlessRuntime(): {
|
||||
runtime: OrcaRuntimeService
|
||||
snapshots: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
} {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the published snapshot map is protected; seeding it stands in for a prior publication.
|
||||
const internals = runtime as unknown as {
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
}
|
||||
const snapshots = internals.mobileSessionTabsByWorktree
|
||||
snapshots.set('wt-1', {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-a',
|
||||
activeTabId: 'tab-a::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{ id: 'group-a', activeTabId: 'tab-a', tabOrder: ['tab-a', 'tab-b'] },
|
||||
{ id: 'group-c', activeTabId: 'tab-c', tabOrder: ['tab-c'] }
|
||||
],
|
||||
tabGroupLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-a' },
|
||||
second: { type: 'leaf', groupId: 'group-c' }
|
||||
},
|
||||
tabs: [terminalTab('tab-a', true), terminalTab('tab-b', false), terminalTab('tab-c', false)]
|
||||
})
|
||||
return { runtime, snapshots }
|
||||
}
|
||||
|
||||
async function expectNoOp(move: RuntimeMobileSessionTabMove): Promise<void> {
|
||||
const { runtime, snapshots } = headlessRuntime()
|
||||
const before = snapshots.get('wt-1')
|
||||
await expect(runtime.moveMobileSessionTab('id:wt-1', move)).resolves.toEqual({ moved: true })
|
||||
expect(snapshots.get('wt-1')).toBe(before)
|
||||
}
|
||||
|
||||
describe('headless session tab moves', () => {
|
||||
it('refuses a split onto a group the host does not have', async () => {
|
||||
const { runtime, snapshots } = headlessRuntime()
|
||||
const before = snapshots.get('wt-1')
|
||||
await expect(
|
||||
runtime.moveMobileSessionTab('id:wt-1', {
|
||||
kind: 'split',
|
||||
tabId: 'tab-b',
|
||||
targetGroupId: 'group-missing',
|
||||
splitDirection: 'right'
|
||||
})
|
||||
).rejects.toThrow('target_group_not_found')
|
||||
expect(snapshots.get('wt-1')).toBe(before)
|
||||
})
|
||||
|
||||
it('refuses a move into a group the host does not have', async () => {
|
||||
const { runtime, snapshots } = headlessRuntime()
|
||||
const before = snapshots.get('wt-1')
|
||||
await expect(
|
||||
runtime.moveMobileSessionTab('id:wt-1', {
|
||||
kind: 'move-to-group',
|
||||
tabId: 'tab-b',
|
||||
targetGroupId: 'group-missing'
|
||||
})
|
||||
).rejects.toThrow('target_group_not_found')
|
||||
expect(snapshots.get('wt-1')).toBe(before)
|
||||
})
|
||||
|
||||
it('keeps a same-group drop a successful no-op', async () => {
|
||||
await expectNoOp({ kind: 'move-to-group', tabId: 'tab-b', targetGroupId: 'group-a' })
|
||||
})
|
||||
|
||||
it('keeps splitting the last tab off its own group a successful no-op', async () => {
|
||||
await expectNoOp({
|
||||
kind: 'split',
|
||||
tabId: 'tab-c',
|
||||
targetGroupId: 'group-c',
|
||||
splitDirection: 'right'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,11 +33,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
if (!hostTabId) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
const publicSnapshot = this.toMobileSessionTabsResult(snapshot)
|
||||
const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId)
|
||||
if (!targetGroup) {
|
||||
throw new Error('target_group_not_found')
|
||||
}
|
||||
const targetGroup = this.requireHeadlessTargetGroup(snapshot, move.targetGroupId)
|
||||
const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder)
|
||||
const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index]))
|
||||
const nextTabs = [...snapshot.tabs].sort((a, b) => {
|
||||
@@ -87,6 +83,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
if (!hostTabId) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
this.requireHeadlessTargetGroup(snapshot, move.targetGroupId)
|
||||
const split = buildHeadlessTabGroupSplit({
|
||||
groups: snapshot.tabGroups ?? [],
|
||||
layout: snapshot.tabGroupLayout,
|
||||
@@ -125,6 +122,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
if (!hostTabId) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
this.requireHeadlessTargetGroup(snapshot, move.targetGroupId)
|
||||
const moved = buildHeadlessTabGroupMove({
|
||||
groups: snapshot.tabGroups ?? [],
|
||||
layout: snapshot.tabGroupLayout,
|
||||
@@ -133,7 +131,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
index: move.index
|
||||
})
|
||||
if (!moved) {
|
||||
// Same-group / missing-target drop is a renderer no-op; mirror that.
|
||||
// Same-group drop is a renderer no-op; mirror that.
|
||||
return { moved: true }
|
||||
}
|
||||
const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId }
|
||||
@@ -152,6 +150,20 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
return { moved: true }
|
||||
}
|
||||
|
||||
// Why: the builders return null for a missing target AND a legit no-op; only the no-op may succeed.
|
||||
protected requireHeadlessTargetGroup(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
targetGroupId: string
|
||||
): RuntimeMobileSessionTabGroup {
|
||||
const targetGroup = this.toMobileSessionTabsResult(snapshot).tabGroups?.find(
|
||||
(group) => group.id === targetGroupId
|
||||
)
|
||||
if (!targetGroup) {
|
||||
throw new Error('target_group_not_found')
|
||||
}
|
||||
return targetGroup
|
||||
}
|
||||
|
||||
// Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows
|
||||
// follow the groups. Deleted by the switch (PR 6), when moves are layout commands.
|
||||
protected persistHeadlessTabGroups(
|
||||
|
||||
Reference in New Issue
Block a user