mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: keep worktree session metadata after CLI init
Worktree sessions briefly showed the expected footer badge and then reverted because the CLI init path produced a second transcript under the actual worktree cwd. The desktop placeholder still held the repository launch metadata, but the startup cleanup removed that placeholder after writing metadata through a lookup that could target the stale source transcript. The runtime session also kept the launch cwd instead of the init cwd. This moves session metadata writes to the transcript matching the CLI init cwd, carries repository launch metadata across duplicate session files, and updates the live ConversationService workDir when the init event reports the real cwd. Constraint: Native CLI worktree creation happens inside the CLI after desktop has already created a placeholder session. Rejected: Patch the footer to remember the first worktree badge | it would hide stale backend state and leave other session APIs on the source checkout. Confidence: high Scope-risk: narrow Directive: Treat the CLI init cwd as authoritative for active desktop sessions; do not let placeholder cleanup discard repository launch metadata. Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern worktree|git-info|placeholder Tested: cd desktop && bun run lint Tested: cd desktop && bun run test -- src/components/shared/ProjectContextChip.test.tsx Tested: git diff --check
This commit is contained in:
parent
3794a9d9ef
commit
5f0ddaecb2
@ -932,6 +932,45 @@ describe('SessionService', () => {
|
||||
await expect(fs.access(worktreeFile)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should move repository metadata to the CLI worktree transcript before deleting placeholders', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(
|
||||
workDir,
|
||||
{ branch: 'main', worktree: true },
|
||||
)
|
||||
const initialLaunchInfo = await service.getSessionLaunchInfo(sessionId)
|
||||
const worktreePath = initialLaunchInfo?.repository?.worktreePath
|
||||
expect(worktreePath).toBeTruthy()
|
||||
|
||||
const worktreeFile = await writeSessionFile(sanitizePath(worktreePath!), sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
cwd: worktreePath,
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
},
|
||||
makeUserEntry('Hello from worktree'),
|
||||
])
|
||||
|
||||
await service.appendSessionMetadata(sessionId, {
|
||||
workDir: worktreePath!,
|
||||
})
|
||||
const removed = await service.deletePlaceholderSessionFiles(sessionId, worktreePath!)
|
||||
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
||||
|
||||
expect(removed).toBe(1)
|
||||
await expect(fs.access(worktreeFile)).resolves.toBeNull()
|
||||
expect(launchInfo?.workDir).toBe(worktreePath)
|
||||
expect(launchInfo?.repository).toMatchObject({
|
||||
requestedWorkDir: await fs.realpath(workDir),
|
||||
branch: 'main',
|
||||
worktree: true,
|
||||
worktreePath,
|
||||
worktreeSlug: initialLaunchInfo?.repository?.worktreeSlug,
|
||||
})
|
||||
})
|
||||
|
||||
it('should recover workDir from transcript cwd when session-meta is missing', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
|
||||
@ -520,6 +520,12 @@ export class ConversationService {
|
||||
return session?.workDir || ''
|
||||
}
|
||||
|
||||
updateSessionWorkDir(sessionId: string, workDir: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !workDir.trim()) return
|
||||
session.workDir = workDir
|
||||
}
|
||||
|
||||
getSessionPermissionMode(sessionId: string): string {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.permissionMode || 'default'
|
||||
|
||||
@ -803,12 +803,11 @@ export class SessionService {
|
||||
* Find the .jsonl file for a given session ID.
|
||||
* Searches across all project directories since sessions may belong to any project.
|
||||
*/
|
||||
async findSessionFile(
|
||||
private async findSessionFiles(
|
||||
sessionId: string
|
||||
): Promise<{ filePath: string; projectDir: string } | null> {
|
||||
// Validate sessionId format to prevent path traversal
|
||||
): Promise<Array<{ filePath: string; projectDir: string }>> {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return null
|
||||
return []
|
||||
}
|
||||
|
||||
const projectsDir = this.getProjectsDir()
|
||||
@ -817,20 +816,27 @@ export class SessionService {
|
||||
try {
|
||||
projectDirs = await fs.readdir(projectsDir)
|
||||
} catch {
|
||||
return null
|
||||
return []
|
||||
}
|
||||
|
||||
const matches: Array<{ filePath: string; projectDir: string }> = []
|
||||
for (const dir of projectDirs) {
|
||||
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return { filePath, projectDir: dir }
|
||||
matches.push({ filePath, projectDir: dir })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return matches
|
||||
}
|
||||
|
||||
async findSessionFile(
|
||||
sessionId: string
|
||||
): Promise<{ filePath: string; projectDir: string } | null> {
|
||||
return (await this.findSessionFiles(sessionId))[0] ?? null
|
||||
}
|
||||
|
||||
private isValidSessionId(id: string): boolean {
|
||||
@ -1460,12 +1466,25 @@ export class SessionService {
|
||||
repository?: PreparedSessionWorkspace['repository']
|
||||
}
|
||||
): Promise<void> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) return
|
||||
const matches = await this.findSessionFiles(sessionId)
|
||||
if (matches.length === 0) return
|
||||
|
||||
const entries = await this.readJsonlFile(found.filePath)
|
||||
const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries)
|
||||
await this.appendJsonlEntry(found.filePath, {
|
||||
let repository = metadata.repository
|
||||
if (!repository) {
|
||||
for (const match of matches) {
|
||||
const candidate = this.resolveRepositoryFromEntries(await this.readJsonlFile(match.filePath))
|
||||
if (candidate) {
|
||||
repository = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetProjectDir = this.sanitizePath(metadata.workDir)
|
||||
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
|
||||
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
|
||||
|
||||
await this.appendJsonlEntry(targetFilePath, {
|
||||
type: 'session-meta',
|
||||
isMeta: true,
|
||||
workDir: metadata.workDir,
|
||||
@ -1474,7 +1493,7 @@ export class SessionService {
|
||||
})
|
||||
|
||||
if (metadata.customTitle) {
|
||||
await this.appendJsonlEntry(found.filePath, {
|
||||
await this.appendJsonlEntry(targetFilePath, {
|
||||
type: 'custom-title',
|
||||
customTitle: metadata.customTitle,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@ -775,6 +775,7 @@ function markPrewarmed(sessionId: string) {
|
||||
function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
|
||||
if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return
|
||||
if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) {
|
||||
conversationService.updateSessionWorkDir(sessionId, cliMsg.cwd)
|
||||
void (async () => {
|
||||
await sessionService.appendSessionMetadata(sessionId, {
|
||||
workDir: cliMsg.cwd,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user