mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* Prevent deleted workspace browser snapshot resurrection * fix: tear down folder workspace browser tabs * fix: fence pre-publication browser snapshots * fix: route folder deletion through runtime cleanup * chore: retrigger CI * fix: sweep folder PTYs on runtime deletion * fix: restore deletion fences after runtime refactor * test: cover deleted renderer snapshot after recreation * fix: avoid publishing ambiguous worktree snapshots * fix: preserve optional worktree index state * fix: fence paired PTYs on worktree removal * fix: harden deletion fence and folder-delete teardown - Folder-group delete no longer fails on a mixed-host group: an ambiguous connection skips the PTY sweep instead of rejecting the delete. - Share one folder-workspace PTY teardown helper between the runtime removal path and the project-group controller. - Simplify the mobile snapshot fence: identity-carrying frames are judged against the live catalog instanceId and clear the fence once the successor is accepted; identity-less frames are fenced by renderer generation. Drops the unbounded epoch bookkeeping. - A fenced frame no longer triggers a resync request on every sync while the renderer still lists it as unchanged. - Cross-host id collisions publish without an instanceId rather than blanking the mobile session for that workspace. - Folder delete IPC always routes through the runtime; the store-only fallback and double notify are gone. - Drop the redundant rescue-path tombstone check; ownership is purged at removal. - Fence tests drive removeWorktreeMetadataAndHistory + syncWindowGraph instead of seeding the fence map, and add accept-after-recreate, no-resync, and ambiguous-host folder delete cases.
241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
|
import type { ProjectGroup } from '../../shared/project-group-types'
|
|
import type { Repo } from '../../shared/repo-types'
|
|
import type {
|
|
FolderWorkspacePathStatus,
|
|
FolderWorkspacePathStatusRequest
|
|
} from '../../shared/folder-workspace-path-status'
|
|
import {
|
|
assertFolderWorkspacePathUsable,
|
|
getFolderWorkspacePathStatus,
|
|
getFolderWorkspacePathStatusForPath
|
|
} from '../project-groups/folder-workspace-path-status'
|
|
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
|
import type { RuntimeStore } from './runtime-store-contract'
|
|
import { folderWorkspaceKey } from '../../shared/workspace-scope'
|
|
|
|
type RuntimeProjectGroupDependencies = {
|
|
getStore: () => RuntimeStore | null
|
|
resolveRepo: (selector: string) => Promise<Repo>
|
|
notifyReposChanged: () => void
|
|
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
|
|
teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise<void>
|
|
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void
|
|
}
|
|
|
|
type FolderWorkspaceUpdates = Partial<
|
|
Pick<
|
|
FolderWorkspace,
|
|
| 'name'
|
|
| 'folderPath'
|
|
| 'linkedTask'
|
|
| 'linkedTaskSourceContext'
|
|
| 'comment'
|
|
| 'isArchived'
|
|
| 'isUnread'
|
|
| 'isPinned'
|
|
| 'sortOrder'
|
|
| 'manualOrder'
|
|
| 'workspaceStatus'
|
|
| 'createdWithAgent'
|
|
| 'pendingFirstAgentMessageRename'
|
|
| 'firstAgentMessageRenameError'
|
|
| 'lastActivityAt'
|
|
| 'diffComments'
|
|
>
|
|
>
|
|
|
|
export class RuntimeProjectGroupController {
|
|
constructor(private readonly deps: RuntimeProjectGroupDependencies) {}
|
|
|
|
listGroups(): ProjectGroup[] {
|
|
return this.deps.getStore()?.getProjectGroups?.() ?? []
|
|
}
|
|
|
|
listFolderWorkspaces(): FolderWorkspace[] {
|
|
return this.deps.getStore()?.getFolderWorkspaces?.() ?? []
|
|
}
|
|
|
|
async createGroup(input: {
|
|
name: string
|
|
parentPath?: string | null
|
|
connectionId?: string | null
|
|
parentGroupId?: string | null
|
|
createdFrom?: ProjectGroup['createdFrom']
|
|
}): Promise<ProjectGroup> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.createProjectGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const group = store.createProjectGroup({
|
|
name: input.name,
|
|
parentPath: input.parentPath ?? null,
|
|
connectionId: input.connectionId ?? null,
|
|
parentGroupId: input.parentGroupId ?? null,
|
|
createdFrom: input.createdFrom ?? 'manual'
|
|
})
|
|
this.deps.notifyReposChanged()
|
|
return group
|
|
}
|
|
|
|
async updateGroup(
|
|
groupId: string,
|
|
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
|
): Promise<ProjectGroup | null> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.updateProjectGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const updated = store.updateProjectGroup(groupId, updates)
|
|
if (updated) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return updated
|
|
}
|
|
|
|
async deleteGroup(groupId: string): Promise<{ deleted: boolean }> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.deleteProjectGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const deleted = store.deleteProjectGroup(groupId)
|
|
if (deleted) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return { deleted }
|
|
}
|
|
|
|
async moveProject(repoSelector: string, groupId: string | null, order?: number): Promise<Repo> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.moveProjectToGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const repo = await this.deps.resolveRepo(repoSelector)
|
|
const moved = store.moveProjectToGroup(repo.id, groupId, order)
|
|
if (!moved) {
|
|
throw new Error('repo_not_found')
|
|
}
|
|
this.deps.notifyReposChanged()
|
|
return moved
|
|
}
|
|
|
|
async createFolderWorkspace(input: {
|
|
projectGroupId: string
|
|
name?: string
|
|
folderPath?: string | null
|
|
connectionId?: string | null
|
|
creatorProvenance?: FolderWorkspace['creatorProvenance']
|
|
linkedTask?: FolderWorkspace['linkedTask']
|
|
linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext']
|
|
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
|
pendingFirstAgentMessageRename?: boolean
|
|
}): Promise<FolderWorkspace> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.createFolderWorkspace) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const projectGroups = store.getProjectGroups?.() ?? []
|
|
const group = projectGroups.find((entry) => entry.id === input.projectGroupId)
|
|
const folderPath =
|
|
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
|
|
? input.folderPath
|
|
: group?.parentPath
|
|
if (!group || !folderPath) {
|
|
throw new Error('folder_workspace_project_group_not_found')
|
|
}
|
|
const status = await getFolderWorkspacePathStatusForPath(
|
|
{
|
|
folderPath,
|
|
projectGroupId: group.id,
|
|
connectionId: input.connectionId ?? group.connectionId ?? null,
|
|
projectGroups,
|
|
repos: store.getRepos()
|
|
},
|
|
{ getSshFilesystemProvider }
|
|
)
|
|
assertFolderWorkspacePathUsable(status)
|
|
const workspace = store.createFolderWorkspace({
|
|
...input,
|
|
creatorProvenance: input.creatorProvenance ?? { kind: 'host' }
|
|
})
|
|
this.deps.notifyReposChanged()
|
|
return workspace
|
|
}
|
|
|
|
async getFolderPathStatus(
|
|
request: FolderWorkspacePathStatusRequest
|
|
): Promise<FolderWorkspacePathStatus> {
|
|
const store = this.deps.getStore()
|
|
if (!store) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
return getFolderWorkspacePathStatus(store, request, { getSshFilesystemProvider })
|
|
}
|
|
|
|
async updateFolderWorkspace(
|
|
folderWorkspaceId: string,
|
|
updates: FolderWorkspaceUpdates
|
|
): Promise<FolderWorkspace | null> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.updateFolderWorkspace) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
|
|
const workspace = store
|
|
.getFolderWorkspaces?.()
|
|
.find((entry) => entry.id === folderWorkspaceId)
|
|
if (!workspace) {
|
|
return null
|
|
}
|
|
const projectGroups = store.getProjectGroups?.() ?? []
|
|
const status = await getFolderWorkspacePathStatusForPath(
|
|
{
|
|
folderPath: updates.folderPath,
|
|
projectGroupId: workspace.projectGroupId,
|
|
connectionId:
|
|
workspace.connectionId ??
|
|
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
|
|
null,
|
|
projectGroups,
|
|
repos: store.getRepos()
|
|
},
|
|
{ getSshFilesystemProvider }
|
|
)
|
|
assertFolderWorkspacePathUsable(status)
|
|
}
|
|
const updated = store.updateFolderWorkspace(folderWorkspaceId, updates)
|
|
if (updated) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return updated
|
|
}
|
|
|
|
async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.removeFolderWorkspace) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const workspace = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId)
|
|
if (workspace) {
|
|
const worktreeId = folderWorkspaceKey(folderWorkspaceId)
|
|
// Why: a mixed-host group has no single PTY target; forgetting the
|
|
// workspace must still succeed, so skip the sweep instead of failing.
|
|
let connectionId: string | null | undefined
|
|
try {
|
|
connectionId = this.deps.resolveFolderConnectionId(workspace)
|
|
} catch (error) {
|
|
console.warn(`[folder-workspace] skipping PTY teardown for ${worktreeId}:`, error)
|
|
}
|
|
if (connectionId !== undefined) {
|
|
await this.deps.teardownFolderWorkspacePtys(worktreeId, connectionId)
|
|
}
|
|
this.deps.cleanupRemovedFolderWorkspaceState(worktreeId)
|
|
}
|
|
const deleted = store.removeFolderWorkspace(folderWorkspaceId)
|
|
if (deleted) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return { deleted }
|
|
}
|
|
}
|