fix(server): reconcile stale workspaces by pruning missing directories and fully-archived agents
Workspaces whose directories were deleted (e.g. cleaned-up worktrees) and workspaces where every agent has been archived now get auto-archived during reconciliation, cascading to project archival when no active siblings remain. Extracts detectStaleWorkspaces() as a pure function in workspace-registry-model.ts with standalone unit tests.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { watch, type FSWatcher } from 'node:fs'
|
import { watch, type FSWatcher } from 'node:fs'
|
||||||
import { stat } from 'fs/promises'
|
import { readFile, stat } from 'fs/promises'
|
||||||
import { exec } from 'child_process'
|
import { exec } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { join, resolve, sep } from 'path'
|
import { join, resolve, sep } from 'path'
|
||||||
@@ -101,6 +101,7 @@ import { AgentStorage, type StoredAgentRecord } from './agent/agent-storage.js'
|
|||||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js'
|
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js'
|
||||||
import {
|
import {
|
||||||
buildProjectPlacementForCwd,
|
buildProjectPlacementForCwd,
|
||||||
|
detectStaleWorkspaces,
|
||||||
deriveProjectKind,
|
deriveProjectKind,
|
||||||
deriveProjectRootPath,
|
deriveProjectRootPath,
|
||||||
deriveWorkspaceDisplayName,
|
deriveWorkspaceDisplayName,
|
||||||
@@ -184,6 +185,8 @@ const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>()
|
|||||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]
|
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]
|
||||||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150
|
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150
|
||||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000
|
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000
|
||||||
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500
|
||||||
|
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = '__removed__'
|
||||||
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
|
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
|
||||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024
|
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024
|
||||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048
|
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048
|
||||||
@@ -245,6 +248,15 @@ type CheckoutDiffWatchTarget = {
|
|||||||
latestFingerprint: string | null
|
latestFingerprint: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceGitWatchTarget = {
|
||||||
|
cwd: string
|
||||||
|
watchers: FSWatcher[]
|
||||||
|
debounceTimer: NodeJS.Timeout | null
|
||||||
|
refreshPromise: Promise<void> | null
|
||||||
|
refreshQueued: boolean
|
||||||
|
latestFingerprint: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type NormalizedGitOptions = {
|
type NormalizedGitOptions = {
|
||||||
baseBranch?: string
|
baseBranch?: string
|
||||||
createNewBranch: boolean
|
createNewBranch: boolean
|
||||||
@@ -600,6 +612,7 @@ export class Session {
|
|||||||
private nextTerminalStreamId = 1
|
private nextTerminalStreamId = 1
|
||||||
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>()
|
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>()
|
||||||
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>()
|
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>()
|
||||||
|
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>()
|
||||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null
|
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null
|
||||||
private readonly localSpeechModelsDir: string
|
private readonly localSpeechModelsDir: string
|
||||||
private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[]
|
private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[]
|
||||||
@@ -1308,6 +1321,9 @@ export class Session {
|
|||||||
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId)
|
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId)
|
||||||
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId)
|
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId)
|
||||||
const placement = await this.buildProjectPlacement(normalizedWorkspaceId)
|
const placement = await this.buildProjectPlacement(normalizedWorkspaceId)
|
||||||
|
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
|
||||||
|
isGit: placement.checkout.isGit,
|
||||||
|
})
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const nextProjectCreatedAt = existing?.createdAt ?? now
|
const nextProjectCreatedAt = existing?.createdAt ?? now
|
||||||
const nextWorkspaceCreatedAt = existing?.createdAt ?? now
|
const nextWorkspaceCreatedAt = existing?.createdAt ?? now
|
||||||
@@ -1366,8 +1382,32 @@ export class Session {
|
|||||||
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
|
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
|
||||||
const changedWorkspaceIds = new Set<string>()
|
const changedWorkspaceIds = new Set<string>()
|
||||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter((workspace) => !workspace.archivedAt)
|
const activeWorkspaces = (await this.workspaceRegistry.list()).filter((workspace) => !workspace.archivedAt)
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces,
|
||||||
|
agentRecords: (await this.agentStorage.list()).map((agent) => ({
|
||||||
|
cwd: agent.cwd,
|
||||||
|
archivedAt: agent.archivedAt ?? null,
|
||||||
|
})),
|
||||||
|
checkDirectoryExists: async (cwd) => {
|
||||||
|
try {
|
||||||
|
await stat(cwd)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const workspaceId of staleWorkspaceIds) {
|
||||||
|
await this.archiveWorkspaceRecord(workspaceId)
|
||||||
|
changedWorkspaceIds.add(workspaceId)
|
||||||
|
}
|
||||||
|
|
||||||
for (const workspace of activeWorkspaces) {
|
for (const workspace of activeWorkspaces) {
|
||||||
|
if (staleWorkspaceIds.has(workspace.workspaceId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId)
|
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId)
|
||||||
if (result.changed) {
|
if (result.changed) {
|
||||||
changedWorkspaceIds.add(result.workspace.workspaceId)
|
changedWorkspaceIds.add(result.workspace.workspaceId)
|
||||||
@@ -3987,6 +4027,174 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const commonDir = (await readFile(join(gitDir, 'commondir'), 'utf8')).trim()
|
||||||
|
if (commonDir.length > 0) {
|
||||||
|
return resolve(gitDir, commonDir)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Regular repos do not have a commondir file.
|
||||||
|
}
|
||||||
|
return gitDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): void {
|
||||||
|
if (target.debounceTimer) {
|
||||||
|
clearTimeout(target.debounceTimer)
|
||||||
|
target.debounceTimer = null
|
||||||
|
}
|
||||||
|
for (const watcher of target.watchers) {
|
||||||
|
watcher.close()
|
||||||
|
}
|
||||||
|
target.watchers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeWorkspaceGitWatchTarget(cwd: string): void {
|
||||||
|
const workspaceId = normalizePersistedWorkspaceId(cwd)
|
||||||
|
const target = this.workspaceGitWatchTargets.get(workspaceId)
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.closeWorkspaceGitWatchTarget(target)
|
||||||
|
this.workspaceGitWatchTargets.delete(workspaceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string {
|
||||||
|
if (!workspace) {
|
||||||
|
return WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT
|
||||||
|
}
|
||||||
|
return JSON.stringify([
|
||||||
|
workspace.name,
|
||||||
|
workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSkipWorkspaceGitWatchUpdate(
|
||||||
|
workspaceId: string,
|
||||||
|
workspace: WorkspaceDescriptorPayload | null
|
||||||
|
): boolean {
|
||||||
|
const target = this.workspaceGitWatchTargets.get(workspaceId)
|
||||||
|
if (!target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const nextFingerprint = this.workspaceGitDescriptorFingerprint(workspace)
|
||||||
|
if (target.latestFingerprint === nextFingerprint) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
target.latestFingerprint = nextFingerprint
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberWorkspaceGitWatchFingerprint(
|
||||||
|
workspaceId: string,
|
||||||
|
workspace: WorkspaceDescriptorPayload | null
|
||||||
|
): void {
|
||||||
|
const target = this.workspaceGitWatchTargets.get(workspaceId)
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
private primeWorkspaceGitWatchFingerprints(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
this.rememberWorkspaceGitWatchFingerprint(workspace.id, workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleWorkspaceGitWatchRefresh(target: WorkspaceGitWatchTarget): void {
|
||||||
|
if (target.debounceTimer) {
|
||||||
|
clearTimeout(target.debounceTimer)
|
||||||
|
}
|
||||||
|
target.debounceTimer = setTimeout(() => {
|
||||||
|
target.debounceTimer = null
|
||||||
|
void this.refreshWorkspaceGitWatchTarget(target)
|
||||||
|
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): Promise<void> {
|
||||||
|
if (target.refreshPromise) {
|
||||||
|
target.refreshQueued = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.refreshPromise = (async () => {
|
||||||
|
do {
|
||||||
|
target.refreshQueued = false
|
||||||
|
await this.emitWorkspaceUpdateForCwd(target.cwd, {
|
||||||
|
dedupeGitState: true,
|
||||||
|
})
|
||||||
|
} while (target.refreshQueued)
|
||||||
|
})()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.refreshPromise
|
||||||
|
} finally {
|
||||||
|
target.refreshPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureWorkspaceGitWatchTarget(cwd: string): Promise<void> {
|
||||||
|
const workspaceId = normalizePersistedWorkspaceId(cwd)
|
||||||
|
if (this.workspaceGitWatchTargets.has(workspaceId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitDir = await this.resolveCheckoutGitDir(cwd)
|
||||||
|
if (!gitDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const refsRoot = await this.resolveWorkspaceGitRefsRoot(gitDir)
|
||||||
|
const target: WorkspaceGitWatchTarget = {
|
||||||
|
cwd: workspaceId,
|
||||||
|
watchers: [],
|
||||||
|
debounceTimer: null,
|
||||||
|
refreshPromise: null,
|
||||||
|
refreshQueued: false,
|
||||||
|
latestFingerprint: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const watchPath of new Set([join(gitDir, 'HEAD'), join(refsRoot, 'refs', 'heads')])) {
|
||||||
|
let watcher: FSWatcher | null = null
|
||||||
|
try {
|
||||||
|
watcher = watch(watchPath, { recursive: false }, () => {
|
||||||
|
this.scheduleWorkspaceGitWatchRefresh(target)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.warn({ err: error, cwd, watchPath }, 'Failed to start workspace git watcher')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watcher) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.on('error', (error) => {
|
||||||
|
this.sessionLogger.warn({ err: error, cwd, watchPath }, 'Workspace git watcher error')
|
||||||
|
})
|
||||||
|
target.watchers.push(watcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.watchers.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workspaceGitWatchTargets.set(workspaceId, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncWorkspaceGitWatchTarget(
|
||||||
|
cwd: string,
|
||||||
|
options: { isGit: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!options.isGit) {
|
||||||
|
this.removeWorkspaceGitWatchTarget(cwd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureWorkspaceGitWatchTarget(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('git rev-parse --path-format=absolute --show-toplevel', {
|
const { stdout } = await execAsync('git rev-parse --path-format=absolute --show-toplevel', {
|
||||||
@@ -5731,6 +5939,9 @@ export class Session {
|
|||||||
|
|
||||||
await this.projectRegistry.upsert(nextProjectRecord)
|
await this.projectRegistry.upsert(nextProjectRecord)
|
||||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord)
|
await this.workspaceRegistry.upsert(nextWorkspaceRecord)
|
||||||
|
await this.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||||
|
isGit: placement.checkout.isGit,
|
||||||
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
existingWorkspace &&
|
existingWorkspace &&
|
||||||
@@ -5746,11 +5957,13 @@ export class Session {
|
|||||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||||
if (!existing || existing.archivedAt) {
|
if (!existing || existing.archivedAt) {
|
||||||
|
this.removeWorkspaceGitWatchTarget(workspaceId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextArchivedAt = archivedAt ?? new Date().toISOString()
|
const nextArchivedAt = archivedAt ?? new Date().toISOString()
|
||||||
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt)
|
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt)
|
||||||
|
this.removeWorkspaceGitWatchTarget(workspaceId)
|
||||||
|
|
||||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt
|
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt
|
||||||
@@ -5760,7 +5973,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emitWorkspaceUpdateForCwd(cwd: string): Promise<void> {
|
private async emitWorkspaceUpdateForCwd(
|
||||||
|
cwd: string,
|
||||||
|
options?: { dedupeGitState?: boolean }
|
||||||
|
): Promise<void> {
|
||||||
const subscription = this.workspaceUpdatesSubscription
|
const subscription = this.workspaceUpdatesSubscription
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return
|
return
|
||||||
@@ -5774,7 +5990,19 @@ export class Session {
|
|||||||
|
|
||||||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
||||||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId)
|
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId)
|
||||||
if (!workspace || !this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
|
const nextWorkspace =
|
||||||
|
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||||||
|
? workspace
|
||||||
|
: null
|
||||||
|
if (
|
||||||
|
options?.dedupeGitState &&
|
||||||
|
this.shouldSkipWorkspaceGitWatchUpdate(nextWorkspaceId, nextWorkspace)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.rememberWorkspaceGitWatchFingerprint(nextWorkspaceId, nextWorkspace)
|
||||||
|
|
||||||
|
if (!nextWorkspace) {
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||||
kind: 'remove',
|
kind: 'remove',
|
||||||
id: nextWorkspaceId,
|
id: nextWorkspaceId,
|
||||||
@@ -5784,7 +6012,7 @@ export class Session {
|
|||||||
|
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||||
kind: 'upsert',
|
kind: 'upsert',
|
||||||
workspace,
|
workspace: nextWorkspace,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5810,7 +6038,13 @@ export class Session {
|
|||||||
|
|
||||||
for (const workspaceId of uniqueWorkspaceCwds) {
|
for (const workspaceId of uniqueWorkspaceCwds) {
|
||||||
const workspace = descriptorsByWorkspaceId.get(workspaceId)
|
const workspace = descriptorsByWorkspaceId.get(workspaceId)
|
||||||
if (!workspace || !this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
|
const nextWorkspace =
|
||||||
|
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||||||
|
? workspace
|
||||||
|
: null
|
||||||
|
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace)
|
||||||
|
|
||||||
|
if (!nextWorkspace) {
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||||
kind: 'remove',
|
kind: 'remove',
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
@@ -5820,7 +6054,7 @@ export class Session {
|
|||||||
|
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||||
kind: 'upsert',
|
kind: 'upsert',
|
||||||
workspace,
|
workspace: nextWorkspace,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5906,6 +6140,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await this.listFetchWorkspacesEntries(request)
|
const payload = await this.listFetchWorkspacesEntries(request)
|
||||||
|
this.primeWorkspaceGitWatchFingerprints(payload.entries)
|
||||||
const snapshotLatestActivityByWorkspaceId = new Map<string, number>()
|
const snapshotLatestActivityByWorkspaceId = new Map<string, number>()
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const parsedLatestActivity = entry.activityAt
|
const parsedLatestActivity = entry.activityAt
|
||||||
@@ -7242,6 +7477,11 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.checkoutDiffTargets.clear()
|
this.checkoutDiffTargets.clear()
|
||||||
this.checkoutDiffSubscriptions.clear()
|
this.checkoutDiffSubscriptions.clear()
|
||||||
|
|
||||||
|
for (const target of this.workspaceGitWatchTargets.values()) {
|
||||||
|
this.closeWorkspaceGitWatchTarget(target)
|
||||||
|
}
|
||||||
|
this.workspaceGitWatchTargets.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function createSessionForWorkspaceTests(): Session {
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Session({
|
const session = new Session({
|
||||||
clientId: 'test-client',
|
clientId: 'test-client',
|
||||||
onMessage: vi.fn(),
|
onMessage: vi.fn(),
|
||||||
logger: logger as any,
|
logger: logger as any,
|
||||||
@@ -108,7 +108,8 @@ function createSessionForWorkspaceTests(): Session {
|
|||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
})
|
}) as any
|
||||||
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('workspace aggregation', () => {
|
describe('workspace aggregation', () => {
|
||||||
@@ -528,11 +529,14 @@ describe('workspace aggregation', () => {
|
|||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
||||||
|
|
||||||
const mainWorkspaceId = '/tmp/inkwell'
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-workspace-reconcile-')))
|
||||||
const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a'
|
const mainWorkspaceId = path.join(tempDir, 'inkwell')
|
||||||
|
const worktreeWorkspaceId = path.join(mainWorkspaceId, '.paseo', 'worktrees', 'feature-a')
|
||||||
const localProjectId = mainWorkspaceId
|
const localProjectId = mainWorkspaceId
|
||||||
const remoteProjectId = 'remote:github.com/zimakki/inkwell'
|
const remoteProjectId = 'remote:github.com/zimakki/inkwell'
|
||||||
|
|
||||||
|
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`)
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
localProjectId,
|
localProjectId,
|
||||||
createPersistedProjectRecord({
|
createPersistedProjectRecord({
|
||||||
@@ -595,25 +599,29 @@ describe('workspace aggregation', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await session.handleMessage({
|
try {
|
||||||
type: 'open_project_request',
|
await session.handleMessage({
|
||||||
cwd: worktreeWorkspaceId,
|
type: 'open_project_request',
|
||||||
requestId: 'req-open-worktree',
|
cwd: worktreeWorkspaceId,
|
||||||
})
|
requestId: 'req-open-worktree',
|
||||||
|
})
|
||||||
|
|
||||||
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId)
|
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId)
|
||||||
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId)
|
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId)
|
||||||
expect(projects.get(localProjectId)?.archivedAt).toBeTruthy()
|
expect(projects.get(localProjectId)?.archivedAt).toBeTruthy()
|
||||||
|
|
||||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||||
expect(workspaceUpdates).toHaveLength(2)
|
expect(workspaceUpdates).toHaveLength(2)
|
||||||
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
|
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
|
||||||
mainWorkspaceId,
|
mainWorkspaceId,
|
||||||
worktreeWorkspaceId,
|
worktreeWorkspaceId,
|
||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
workspaceUpdates.every((message) => message.payload.workspace.projectId === remoteProjectId)
|
workspaceUpdates.every((message) => message.payload.workspace.projectId === remoteProjectId)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('fetch_workspaces_request reconciles remote URL changes for existing workspaces', async () => {
|
test('fetch_workspaces_request reconciles remote URL changes for existing workspaces', async () => {
|
||||||
@@ -621,11 +629,14 @@ describe('workspace aggregation', () => {
|
|||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
||||||
|
|
||||||
const mainWorkspaceId = '/tmp/inkwell'
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-workspace-fetch-')))
|
||||||
const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a'
|
const mainWorkspaceId = path.join(tempDir, 'inkwell')
|
||||||
|
const worktreeWorkspaceId = path.join(mainWorkspaceId, '.paseo', 'worktrees', 'feature-a')
|
||||||
const oldProjectId = 'remote:github.com/old-owner/inkwell'
|
const oldProjectId = 'remote:github.com/old-owner/inkwell'
|
||||||
const newProjectId = 'remote:github.com/new-owner/inkwell'
|
const newProjectId = 'remote:github.com/new-owner/inkwell'
|
||||||
|
|
||||||
|
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`)
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
oldProjectId,
|
oldProjectId,
|
||||||
createPersistedProjectRecord({
|
createPersistedProjectRecord({
|
||||||
@@ -687,14 +698,18 @@ describe('workspace aggregation', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await session.listFetchWorkspacesEntries({
|
try {
|
||||||
type: 'fetch_workspaces_request',
|
const result = await session.listFetchWorkspacesEntries({
|
||||||
requestId: 'req-fetch-reconcile',
|
type: 'fetch_workspaces_request',
|
||||||
})
|
requestId: 'req-fetch-reconcile',
|
||||||
|
})
|
||||||
|
|
||||||
expect(result.entries.map((entry: any) => entry.projectId)).toEqual([newProjectId, newProjectId])
|
expect(result.entries.map((entry: any) => entry.projectId)).toEqual([newProjectId, newProjectId])
|
||||||
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId)
|
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId)
|
||||||
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId)
|
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId)
|
||||||
expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy()
|
expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy()
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { detectStaleWorkspaces } from './workspace-registry-model.js'
|
||||||
|
import { createPersistedWorkspaceRecord } from './workspace-registry.js'
|
||||||
|
|
||||||
|
function createWorkspaceRecord(workspaceId: string) {
|
||||||
|
return createPersistedWorkspaceRecord({
|
||||||
|
workspaceId,
|
||||||
|
projectId: workspaceId,
|
||||||
|
cwd: workspaceId,
|
||||||
|
kind: 'directory',
|
||||||
|
displayName: workspaceId.split('/').at(-1) ?? workspaceId,
|
||||||
|
createdAt: '2026-03-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectStaleWorkspaces', () => {
|
||||||
|
test('returns workspace ids whose directories no longer exist', async () => {
|
||||||
|
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== '/tmp/missing')
|
||||||
|
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces: [
|
||||||
|
createWorkspaceRecord('/tmp/existing'),
|
||||||
|
createWorkspaceRecord('/tmp/missing'),
|
||||||
|
],
|
||||||
|
agentRecords: [],
|
||||||
|
checkDirectoryExists,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.from(staleWorkspaceIds)).toEqual(['/tmp/missing'])
|
||||||
|
expect(checkDirectoryExists.mock.calls).toEqual([['/tmp/existing'], ['/tmp/missing']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns workspace ids when all matching agents are archived', async () => {
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces: [
|
||||||
|
createWorkspaceRecord('/tmp/repo'),
|
||||||
|
createWorkspaceRecord('/tmp/other'),
|
||||||
|
],
|
||||||
|
agentRecords: [
|
||||||
|
{
|
||||||
|
cwd: '/tmp/repo',
|
||||||
|
archivedAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: '/tmp/other',
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkDirectoryExists: async () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.from(staleWorkspaceIds)).toEqual(['/tmp/repo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps workspaces with no agents or at least one active agent', async () => {
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces: [
|
||||||
|
createWorkspaceRecord('/tmp/active'),
|
||||||
|
createWorkspaceRecord('/tmp/no-agents'),
|
||||||
|
],
|
||||||
|
agentRecords: [
|
||||||
|
{
|
||||||
|
cwd: '/tmp/active',
|
||||||
|
archivedAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: '/tmp/active/../active',
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkDirectoryExists: async () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.from(staleWorkspaceIds)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,9 +2,20 @@ import { resolve } from 'node:path'
|
|||||||
|
|
||||||
import { getCheckoutStatusLite } from '../utils/checkout-git.js'
|
import { getCheckoutStatusLite } from '../utils/checkout-git.js'
|
||||||
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from '../shared/messages.js'
|
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from '../shared/messages.js'
|
||||||
|
import type { PersistedWorkspaceRecord } from './workspace-registry.js'
|
||||||
|
|
||||||
export type PersistedProjectKind = 'git' | 'non_git'
|
export type PersistedProjectKind = 'git' | 'non_git'
|
||||||
export type PersistedWorkspaceKind = 'local_checkout' | 'worktree' | 'directory'
|
export type PersistedWorkspaceKind = 'local_checkout' | 'worktree' | 'directory'
|
||||||
|
export type StaleWorkspaceAgentRecord = {
|
||||||
|
cwd: string
|
||||||
|
archivedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetectStaleWorkspacesInput = {
|
||||||
|
activeWorkspaces: PersistedWorkspaceRecord[]
|
||||||
|
agentRecords: StaleWorkspaceAgentRecord[]
|
||||||
|
checkDirectoryExists: (cwd: string) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeWorkspaceId(cwd: string): string {
|
export function normalizeWorkspaceId(cwd: string): string {
|
||||||
const trimmed = cwd.trim()
|
const trimmed = cwd.trim()
|
||||||
@@ -128,6 +139,38 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
|
|||||||
return checkout.isPaseoOwnedWorktree ? 'worktree' : 'local_checkout'
|
return checkout.isPaseoOwnedWorktree ? 'worktree' : 'local_checkout'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detectStaleWorkspaces(
|
||||||
|
input: DetectStaleWorkspacesInput
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const staleWorkspaceIds = new Set<string>()
|
||||||
|
const cwdsWithActiveAgents = new Set<string>()
|
||||||
|
const cwdsWithAnyAgent = new Set<string>()
|
||||||
|
|
||||||
|
for (const agent of input.agentRecords) {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(agent.cwd)
|
||||||
|
cwdsWithAnyAgent.add(normalizedCwd)
|
||||||
|
if (!agent.archivedAt) {
|
||||||
|
cwdsWithActiveAgents.add(normalizedCwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workspace of input.activeWorkspaces) {
|
||||||
|
const dirExists = await input.checkDirectoryExists(workspace.cwd)
|
||||||
|
if (!dirExists) {
|
||||||
|
staleWorkspaceIds.add(workspace.workspaceId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId)
|
||||||
|
const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId)
|
||||||
|
if (hasAgents && !hasActiveAgents) {
|
||||||
|
staleWorkspaceIds.add(workspace.workspaceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return staleWorkspaceIds
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildProjectPlacementForCwd(input: {
|
export async function buildProjectPlacementForCwd(input: {
|
||||||
cwd: string
|
cwd: string
|
||||||
paseoHome: string
|
paseoHome: string
|
||||||
|
|||||||
Reference in New Issue
Block a user