Skip to content

Commit 54685c2

Browse files
authored
Merge pull request #259 from Gucc111/codex/fix-always-on-project-id-resolution
[codex] Fix Always-On project ID resolution
2 parents e55628b + 39c0698 commit 54685c2

9 files changed

Lines changed: 172 additions & 20 deletions

File tree

src/always-on/storage/AlwaysOnPaths.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { resolve } from "node:path";
2-
import { createProjectId } from "../../pilot/paths.js";
2+
import { resolveProjectStorageId } from "../../pilot/paths.js";
33

44
const ROOT_DIR_NAME = "always-on";
55

@@ -32,7 +32,7 @@ export function resolveAlwaysOnPaths(input: {
3232
}): AlwaysOnPaths {
3333
const pilotHome = resolve(input.pilotHome);
3434
const projectKey = resolve(input.projectKey);
35-
const projectId = createProjectId(projectKey);
35+
const projectId = resolveProjectStorageId(projectKey, pilotHome);
3636
const rootDir = resolve(pilotHome, ROOT_DIR_NAME);
3737
const projectDir = resolve(rootDir, "projects", projectId);
3838
const worktreesDir = resolve(input.worktreesBaseDir ?? resolve(rootDir, "worktrees"), projectId);

src/always-on/web/DiscoveryPlanService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export type StateManager = {
109109

110110
export type DiscoveryPlanServiceDeps = {
111111
pilotHome: string;
112-
createProjectId: (projectRoot: string) => string;
112+
resolveProjectId: (projectRoot: string) => string;
113113
paths: ProjectPathResolver;
114114
sessions: SessionLister;
115115
activity: SessionActivityChecker;
@@ -122,8 +122,8 @@ export type DiscoveryPlanServiceDeps = {
122122
// Paths (mirrors ui/server/discovery-plans.js helpers)
123123
// ---------------------------------------------------------------------------
124124

125-
function resolveProjectDir(pilotHome: string, createProjectId: (root: string) => string, projectRoot: string): string {
126-
const projectId = createProjectId(resolve(projectRoot));
125+
function resolveProjectDir(pilotHome: string, resolveProjectId: (root: string) => string, projectRoot: string): string {
126+
const projectId = resolveProjectId(resolve(projectRoot));
127127
return join(pilotHome, "always-on", "projects", projectId);
128128
}
129129

@@ -318,7 +318,7 @@ export class DiscoveryPlanService {
318318
}
319319

320320
private projectDir(projectRoot: string): string {
321-
return resolveProjectDir(this.deps.pilotHome, this.deps.createProjectId, projectRoot);
321+
return resolveProjectDir(this.deps.pilotHome, this.deps.resolveProjectId, projectRoot);
322322
}
323323

324324
async getPlansOverview(projectName: string) {

src/pilot/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
createProjectId,
66
createProjectIdAsync,
77
createCollisionResistantProjectId,
8+
resolveProjectStorageId,
89
getPilotConfigFilePath,
910
getPilotExtensionPaths,
1011
getPilotProjectConfigFilePath,

src/pilot/paths.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function getPilotMemoryRootDir(pilotHome: string): string {
3434
}
3535

3636
export function getPilotProjectChatDir(projectRoot: string, pilotHome: string): string {
37-
const projectId = resolveStoredProjectId(projectRoot, pilotHome) ?? createProjectId(projectRoot);
37+
const projectId = resolveProjectStorageId(projectRoot, pilotHome);
3838
return resolve(pilotHome, "projects", projectId, "chats");
3939
}
4040

@@ -49,7 +49,7 @@ export async function getPilotProjectChatDirAsync(
4949
pilotHome: string,
5050
): Promise<string> {
5151
const canonical = await findCanonicalProjectRoot(projectRoot);
52-
const projectId = resolveStoredProjectId(canonical, pilotHome) ?? createProjectId(canonical);
52+
const projectId = resolveProjectStorageId(canonical, pilotHome);
5353
return resolve(pilotHome, "projects", projectId, "chats");
5454
}
5555

@@ -74,6 +74,18 @@ export function createCollisionResistantProjectId(projectRoot: string): string {
7474
return `${legacyId}--${digest}`;
7575
}
7676

77+
/**
78+
* Resolve the on-disk project directory name for a workspace.
79+
*
80+
* `.cwd` markers are authoritative because the legacy project ID is lossy:
81+
* distinct paths (especially paths containing non-ASCII segments) can encode
82+
* to the same slug. When no valid marker exists, retain the legacy ID for
83+
* backwards compatibility with unregistered projects.
84+
*/
85+
export function resolveProjectStorageId(projectRoot: string, pilotHome: string): string {
86+
return findStoredProjectId(projectRoot, pilotHome) ?? createProjectId(projectRoot);
87+
}
88+
7789
/**
7890
* Async variant: resolves canonical (worktree-aware) root before hashing.
7991
* Two worktrees of the same repo produce the same project ID.
@@ -103,7 +115,7 @@ function createLegacyProjectId(projectRoot: string): string {
103115
return normalized.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
104116
}
105117

106-
function resolveStoredProjectId(projectRoot: string, pilotHome: string): string | null {
118+
function findStoredProjectId(projectRoot: string, pilotHome: string): string | null {
107119
const projectsDir = resolve(pilotHome, "projects");
108120
if (!existsSync(projectsDir)) {
109121
return null;

ui/server/discovery-plans.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
appendAlwaysOnRunLogEvent,
2020
formatAlwaysOnPlanLogLine,
2121
} from './services/always-on-run-logs.js';
22-
import { resolvePilotHome, createProjectId } from './utils/pilotPaths.js';
22+
import { resolvePilotHome, resolveProjectStorageId } from './utils/pilotPaths.js';
2323

2424
import { DiscoveryPlanService } from '../../src/always-on/web/DiscoveryPlanService.js';
2525
import { buildDiscoveryContext } from '../../src/always-on/web/DiscoveryPlanContext.js';
@@ -35,9 +35,10 @@ import { DiscoveryStateStore } from '../../src/always-on/storage/DiscoveryStateS
3535
// ---------------------------------------------------------------------------
3636

3737
function getService() {
38+
const pilotHome = resolvePilotHome();
3839
return new DiscoveryPlanService({
39-
pilotHome: resolvePilotHome(),
40-
createProjectId,
40+
pilotHome,
41+
resolveProjectId: (projectRoot) => resolveProjectStorageId(projectRoot, pilotHome),
4142
paths: { extractProjectDirectory },
4243
sessions: { getSessions },
4344
activity: { isSessionActive: isClaudeSDKSessionActive },
@@ -54,7 +55,7 @@ function getService() {
5455
state: {
5556
clearActiveWorkCycleId: async (projectRoot) => {
5657
const paths = resolveAlwaysOnPaths({
57-
pilotHome: resolvePilotHome(),
58+
pilotHome,
5859
projectKey: projectRoot,
5960
});
6061
const store = new DiscoveryStateStore(paths);

ui/server/services/always-on-events.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readdir, readFile } from 'node:fs/promises';
22
import { resolve } from 'node:path';
3-
import { resolvePilotHome, createProjectId } from '../utils/pilotPaths.js';
3+
import { resolvePilotHome, resolveProjectStorageId } from '../utils/pilotPaths.js';
44
import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
55

66
/**
@@ -33,14 +33,14 @@ async function readProjectEvents(projectDir) {
3333
/**
3434
* Build a lookup from projectKey -> { projectName, projectDisplayName }.
3535
*/
36-
async function buildProjectLookup() {
36+
async function buildProjectLookup(pilotHome) {
3737
const gateway = await getPilotDeckGateway();
3838
const { projects } = await gateway.listProjects();
3939
const lookup = new Map();
4040
for (const project of projects) {
4141
const key = resolve(project.projectKey ?? project.fullPath ?? '');
4242
if (!key) continue;
43-
const name = createProjectId(key);
43+
const name = resolveProjectStorageId(key, pilotHome);
4444
const displayName = project.displayName || key.split(/[\\/]/).pop() || name;
4545
lookup.set(key, { projectName: name, projectDisplayName: displayName });
4646
}
@@ -65,7 +65,7 @@ export async function getAlwaysOnDashboardEvents(opts = {}) {
6565
return { events: [] };
6666
}
6767

68-
const lookup = await buildProjectLookup().catch(() => new Map());
68+
const lookup = await buildProjectLookup(pilotHome).catch(() => new Map());
6969

7070
const allEvents = [];
7171
for (const entry of projectDirs) {
@@ -93,7 +93,7 @@ export async function getAlwaysOnDashboardEvents(opts = {}) {
9393
const events = filtered.map((event) => {
9494
const key = resolve(event.projectKey || '');
9595
const info = lookup.get(key) || {
96-
projectName: createProjectId(key || 'unknown'),
96+
projectName: resolveProjectStorageId(key || 'unknown', pilotHome),
9797
projectDisplayName: key.split(/[\\/]/).pop() || 'Unknown',
9898
};
9999
return {

ui/server/services/always-on-paths.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import path from 'path';
2-
import { resolvePilotHome, createProjectId } from '../utils/pilotPaths.js';
2+
import { resolvePilotHome, resolveProjectStorageId } from '../utils/pilotPaths.js';
33

44
export function getAlwaysOnRoot(projectRoot) {
55
const pilotHome = resolvePilotHome();
6-
const projectId = createProjectId(path.resolve(projectRoot));
6+
const projectId = resolveProjectStorageId(path.resolve(projectRoot), pilotHome);
77
return path.join(pilotHome, 'always-on', 'projects', projectId);
88
}
99

ui/server/utils/pilotPaths.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { homedir } from 'node:os';
1616
import { resolve } from 'node:path';
1717
import { createHash } from 'node:crypto';
18+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
1819

1920
export const DEFAULT_PILOT_HOME = '~/.pilotdeck';
2021

@@ -59,6 +60,21 @@ export function createCollisionResistantProjectId(projectRoot) {
5960
return `${legacyId}--${digest}`;
6061
}
6162

63+
/**
64+
* Resolve the on-disk project directory name for a workspace.
65+
*
66+
* `.cwd` markers disambiguate paths that collapse to the same legacy slug.
67+
* If no valid marker exists, preserve the legacy ID for compatibility with
68+
* unregistered workspaces.
69+
*
70+
* @param {string} projectRoot Absolute filesystem path.
71+
* @param {string} [pilotHome] Active PilotDeck home directory.
72+
* @returns {string} Project directory name under `<pilotHome>/projects`.
73+
*/
74+
export function resolveProjectStorageId(projectRoot, pilotHome = resolvePilotHome()) {
75+
return findStoredProjectId(projectRoot, pilotHome) ?? createProjectId(projectRoot);
76+
}
77+
6278
/**
6379
* Sanitize a sessionId for safe use as a filename component.
6480
*
@@ -86,3 +102,30 @@ function createLegacyProjectId(projectRoot) {
86102
return normalized.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
87103
}
88104

105+
function findStoredProjectId(projectRoot, pilotHome) {
106+
const projectsDir = resolve(pilotHome, 'projects');
107+
if (!existsSync(projectsDir)) return null;
108+
109+
const target = resolve(projectRoot);
110+
try {
111+
for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
112+
if (!entry.isDirectory()) continue;
113+
const markerPath = resolve(projectsDir, entry.name, '.cwd');
114+
let marker;
115+
try {
116+
marker = readFileSync(markerPath, 'utf8').trim();
117+
} catch {
118+
continue;
119+
}
120+
if (!marker || resolve(marker) !== target) continue;
121+
try {
122+
if (statSync(marker).isDirectory()) return entry.name;
123+
} catch {
124+
continue;
125+
}
126+
}
127+
} catch {
128+
return null;
129+
}
130+
return null;
131+
}

ui/server/utils/pilotPaths.test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { describe, expect, it } from 'vitest';
5+
import {
6+
createCollisionResistantProjectId as createCoreCollisionId,
7+
createProjectId as createCoreProjectId,
8+
resolveProjectStorageId as resolveCoreProjectStorageId,
9+
} from '../../../src/pilot/paths.js';
10+
import {
11+
createCollisionResistantProjectId,
12+
createProjectId,
13+
resolveProjectStorageId,
14+
} from './pilotPaths.js';
15+
import { getAlwaysOnRoot } from '../services/always-on-paths.js';
16+
17+
describe('UI project storage ID resolution', () => {
18+
it('matches the core resolver for colliding non-ASCII workspaces', () => {
19+
const root = mkdtempSync(join(tmpdir(), 'pilotdeck-ui-project-id-'));
20+
try {
21+
const pilotHome = join(root, 'pilot-home');
22+
const projectA = join(root, 'home', '内部测试');
23+
const projectB = join(root, 'home', '会议纪要');
24+
mkdirSync(projectA, { recursive: true });
25+
mkdirSync(projectB, { recursive: true });
26+
27+
const legacyId = createProjectId(projectA);
28+
const collisionId = createCollisionResistantProjectId(projectB);
29+
expect(createProjectId(projectB)).toBe(legacyId);
30+
31+
mkdirSync(join(pilotHome, 'projects', legacyId), { recursive: true });
32+
writeFileSync(join(pilotHome, 'projects', legacyId, '.cwd'), projectA, 'utf8');
33+
mkdirSync(join(pilotHome, 'projects', collisionId), { recursive: true });
34+
writeFileSync(join(pilotHome, 'projects', collisionId, '.cwd'), projectB, 'utf8');
35+
36+
expect(createProjectId(projectA)).toBe(createCoreProjectId(projectA));
37+
expect(collisionId).toBe(createCoreCollisionId(projectB));
38+
expect(resolveProjectStorageId(projectA, pilotHome)).toBe(legacyId);
39+
expect(resolveProjectStorageId(projectB, pilotHome)).toBe(collisionId);
40+
expect(resolveProjectStorageId(projectA, pilotHome)).toBe(
41+
resolveCoreProjectStorageId(projectA, pilotHome),
42+
);
43+
expect(resolveProjectStorageId(projectB, pilotHome)).toBe(
44+
resolveCoreProjectStorageId(projectB, pilotHome),
45+
);
46+
} finally {
47+
rmSync(root, { recursive: true, force: true });
48+
}
49+
});
50+
51+
it('matches core fallback behavior for missing and invalid markers', () => {
52+
const root = mkdtempSync(join(tmpdir(), 'pilotdeck-ui-project-id-fallback-'));
53+
try {
54+
const pilotHome = join(root, 'pilot-home');
55+
const projectRoot = join(root, 'workspace', 'ascii-project');
56+
mkdirSync(projectRoot, { recursive: true });
57+
58+
const invalidId = createCollisionResistantProjectId(projectRoot);
59+
mkdirSync(join(pilotHome, 'projects', invalidId), { recursive: true });
60+
writeFileSync(join(pilotHome, 'projects', invalidId, '.cwd'), join(root, 'missing'), 'utf8');
61+
62+
expect(resolveProjectStorageId(projectRoot, pilotHome)).toBe(createProjectId(projectRoot));
63+
expect(resolveProjectStorageId(projectRoot, pilotHome)).toBe(
64+
resolveCoreProjectStorageId(projectRoot, pilotHome),
65+
);
66+
} finally {
67+
rmSync(root, { recursive: true, force: true });
68+
}
69+
});
70+
71+
it('uses the resolved storage ID for the UI Always-On root', () => {
72+
const root = mkdtempSync(join(tmpdir(), 'pilotdeck-ui-always-on-root-'));
73+
const previousPilotHome = process.env.PILOT_HOME;
74+
try {
75+
const pilotHome = join(root, 'pilot-home');
76+
const projectRoot = join(root, 'home', '会议纪要');
77+
const projectId = createCollisionResistantProjectId(projectRoot);
78+
mkdirSync(projectRoot, { recursive: true });
79+
mkdirSync(join(pilotHome, 'projects', projectId), { recursive: true });
80+
writeFileSync(join(pilotHome, 'projects', projectId, '.cwd'), projectRoot, 'utf8');
81+
process.env.PILOT_HOME = pilotHome;
82+
83+
expect(getAlwaysOnRoot(projectRoot)).toBe(
84+
join(pilotHome, 'always-on', 'projects', projectId),
85+
);
86+
} finally {
87+
if (previousPilotHome === undefined) {
88+
delete process.env.PILOT_HOME;
89+
} else {
90+
process.env.PILOT_HOME = previousPilotHome;
91+
}
92+
rmSync(root, { recursive: true, force: true });
93+
}
94+
});
95+
});

0 commit comments

Comments
 (0)