Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 592ac71

Browse files
authored
Merge pull request #46 from closedloop-ai/FEAT-169
Migrate work directory from .claude/work to .closedloop-ai/work
2 parents 45cde76 + 6480bb0 commit 592ac71

23 files changed

Lines changed: 3110 additions & 321 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.8.8",
3+
"version": "0.8.9",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/server/operations/codex.ts

Lines changed: 165 additions & 60 deletions
Large diffs are not rendered by default.

apps/desktop/src/server/operations/deploy.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type RepoDeploymentConfig,
1212
type ReposConfig
1313
} from "./repos-config-utils.js";
14-
import { expandHome } from "./symphony-utils.js";
14+
import { checkAndMigrateLegacyWorkDir, expandHome, findFirstExisting } from "./symphony-utils.js";
1515

1616
type DeployStatus = "running" | "completed" | "failed" | "not-started";
1717

@@ -79,7 +79,13 @@ export function registerDeployRoutes(
7979
await saveReposConfig(reposConfig, configDir());
8080
}
8181

82-
const claudeWorkDir = path.join(expandedWorktreePath, ".claude", "work");
82+
const migrationResult = checkAndMigrateLegacyWorkDir(expandedWorktreePath);
83+
if (migrationResult === "blocked") {
84+
json(context, 409, { error: "A job started before the .closedloop-ai migration is still running. Stop it first, then retry." });
85+
return;
86+
}
87+
88+
const claudeWorkDir = path.join(expandedWorktreePath, ".closedloop-ai", "work");
8389
await fs.mkdir(claudeWorkDir, { recursive: true });
8490

8591
const logFile = path.join(claudeWorkDir, "deploy.log");
@@ -124,6 +130,8 @@ export function registerDeployRoutes(
124130
throw new Error("failed to start deploy process");
125131
}
126132

133+
await fs.writeFile(path.join(claudeWorkDir, "process.pid"), String(child.pid));
134+
127135
child.on("exit", (code) => {
128136
if (code === 0) {
129137
return;
@@ -351,17 +359,31 @@ export function registerDeployRoutes(
351359
throw error;
352360
}
353361

354-
const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
355-
const logs = await readTextFile(path.join(claudeWorkDir, "deploy.log"));
356-
const exitInfo = await readJsonFile<{ exitCode: number; failedCommand: string }>(
357-
path.join(claudeWorkDir, "deploy-exit.json")
362+
const newDeployWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
363+
const oldDeployWorkDir = path.join(worktreeDir, ".claude", "work");
364+
// Per-file resolution: each deploy artifact may be at either location
365+
const logsPath = findFirstExisting(
366+
path.join(newDeployWorkDir, "deploy.log"),
367+
path.join(oldDeployWorkDir, "deploy.log")
368+
);
369+
const exitInfoPath = findFirstExisting(
370+
path.join(newDeployWorkDir, "deploy-exit.json"),
371+
path.join(oldDeployWorkDir, "deploy-exit.json")
358372
);
359-
const deployResult = await readJsonFile<{ url?: string; serviceId?: string }>(
360-
path.join(claudeWorkDir, "deploy-result.json")
373+
const deployResultPath = findFirstExisting(
374+
path.join(newDeployWorkDir, "deploy-result.json"),
375+
path.join(oldDeployWorkDir, "deploy-result.json")
361376
);
377+
const logs = logsPath ? await readTextFile(logsPath) : null;
378+
const exitInfo = exitInfoPath
379+
? await readJsonFile<{ exitCode: number; failedCommand: string }>(exitInfoPath)
380+
: null;
381+
const deployResult = deployResultPath
382+
? await readJsonFile<{ url?: string; serviceId?: string }>(deployResultPath)
383+
: null;
362384

363385
const processAlive = isProcessAlive(pidRaw);
364-
const status = determineStatus(exitInfo, deployResult?.url, processAlive, logs, pidRaw);
386+
const status = determineStatus(exitInfo, deployResult?.url, processAlive, logs ?? "", pidRaw);
365387

366388
json(context, 200, {
367389
status,
@@ -606,7 +628,7 @@ function detectDeployment(repoPath: string): RepoDeploymentConfig | null {
606628
};
607629

608630
const framework = detectFramework(deps);
609-
const script = resolveStartCommand(packageJson.scripts ?? {});
631+
const script = resolveStartCommand(packageJson.scripts ?? {}, repoPath);
610632
if (!script) {
611633
return null;
612634
}
@@ -662,21 +684,21 @@ function detectFramework(dependencies: Record<string, string>): string | undefin
662684
return undefined;
663685
}
664686

665-
function resolveStartCommand(scripts: Record<string, string>): string | null {
687+
function resolveStartCommand(scripts: Record<string, string>, repoPath: string): string | null {
666688
if (scripts.dev) {
667-
if (existsSync(path.join(process.cwd(), "pnpm-lock.yaml"))) {
689+
if (existsSync(path.join(repoPath, "pnpm-lock.yaml"))) {
668690
return "pnpm dev";
669691
}
670-
if (existsSync(path.join(process.cwd(), "yarn.lock"))) {
692+
if (existsSync(path.join(repoPath, "yarn.lock"))) {
671693
return "yarn dev";
672694
}
673695
return "npm run dev";
674696
}
675697
if (scripts.start) {
676-
if (existsSync(path.join(process.cwd(), "pnpm-lock.yaml"))) {
698+
if (existsSync(path.join(repoPath, "pnpm-lock.yaml"))) {
677699
return "pnpm start";
678700
}
679-
if (existsSync(path.join(process.cwd(), "yarn.lock"))) {
701+
if (existsSync(path.join(repoPath, "yarn.lock"))) {
680702
return "yarn start";
681703
}
682704
return "npm run start";

apps/desktop/src/server/operations/learnings.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from "node:path";
66
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
77
import { findPluginScript } from "./plugin-cache.js";
88
import { DirectoryNotAllowedError, assertPathAllowed } from "../security.js";
9-
import { assertRepoAllowed, resolveWorktreeDir } from "./symphony-utils.js";
9+
import { assertRepoAllowed, findFirstExisting, resolveWorktreeDir } from "./symphony-utils.js";
1010

1111
type ParsedLearningPattern = {
1212
id: string;
@@ -74,8 +74,14 @@ export function registerLearningsRoutes(
7474
return;
7575
}
7676

77-
const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
78-
const chatHistoryPath = path.join(claudeWorkDir, chatFile);
77+
const newLearningsWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
78+
const oldLearningsWorkDir = path.join(worktreeDir, ".claude", "work");
79+
// Per-file resolution: find chat history wherever it exists
80+
const chatHistoryPath = findFirstExisting(
81+
path.join(newLearningsWorkDir, chatFile),
82+
path.join(oldLearningsWorkDir, chatFile)
83+
) ?? path.join(newLearningsWorkDir, chatFile);
84+
const claudeWorkDir = chatHistoryPath.startsWith(newLearningsWorkDir) ? newLearningsWorkDir : oldLearningsWorkDir;
7985

8086
try {
8187
assertPathAllowed(claudeWorkDir, getAllowedDirectories());
@@ -147,15 +153,12 @@ export function registerLearningsRoutes(
147153
}
148154

149155
const worktreeDir = resolveWorktreeDir(expandedRepoPath, ticketId);
150-
const statusPath = path.join(
151-
worktreeDir,
152-
".claude",
153-
"work",
154-
".learnings",
155-
"processing-status.json"
156+
const statusPath = findFirstExisting(
157+
path.join(worktreeDir, ".closedloop-ai", "work", ".learnings", "processing-status.json"),
158+
path.join(worktreeDir, ".claude", "work", ".learnings", "processing-status.json")
156159
);
157160

158-
if (!existsSync(statusPath)) {
161+
if (!statusPath) {
159162
json(context, 200, { status: "none" });
160163
return;
161164
}
@@ -201,7 +204,9 @@ export function registerLearningsRoutes(
201204
return;
202205
}
203206

204-
const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
207+
const newProcWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
208+
// Always write to the new canonical path; reads may fall back to legacy.
209+
const claudeWorkDir = newProcWorkDir;
205210
const learningsDir = path.join(claudeWorkDir, ".learnings");
206211
const pendingDir = path.join(learningsDir, "pending");
207212
const processingStatusPath = path.join(learningsDir, "processing-status.json");
@@ -228,11 +233,23 @@ export function registerLearningsRoutes(
228233
return;
229234
}
230235

231-
if (!existsSync(pendingDir)) {
236+
// Check both new and legacy locations for pending learnings
237+
const legacyPendingDir = path.join(worktreeDir, ".claude", "work", ".learnings", "pending");
238+
const effectivePendingDir = findFirstExisting(pendingDir, legacyPendingDir);
239+
if (!effectivePendingDir) {
232240
json(context, 200, { status: "skipped", reason: "No pending learnings directory" });
233241
return;
234242
}
235243

244+
// If pending learnings are at legacy location, copy them to new location
245+
if (effectivePendingDir === legacyPendingDir && !existsSync(pendingDir)) {
246+
await fs.mkdir(pendingDir, { recursive: true });
247+
const legacyFiles = await fs.readdir(legacyPendingDir).catch(() => []);
248+
for (const file of legacyFiles) {
249+
await fs.copyFile(path.join(legacyPendingDir, file), path.join(pendingDir, file)).catch(() => {});
250+
}
251+
}
252+
236253
const pendingFiles = await fs
237254
.readdir(pendingDir)
238255
.then((entries) => entries.filter((entry) => entry.endsWith(".json")))
@@ -304,15 +321,12 @@ export function registerLearningsRoutes(
304321
}
305322

306323
const worktreeDir = resolveWorktreeDir(expandedRepoPath, ticketId);
307-
const statusPath = path.join(
308-
worktreeDir,
309-
".claude",
310-
"work",
311-
".learnings",
312-
"chat-extraction-status.json"
324+
const statusPath = findFirstExisting(
325+
path.join(worktreeDir, ".closedloop-ai", "work", ".learnings", "chat-extraction-status.json"),
326+
path.join(worktreeDir, ".claude", "work", ".learnings", "chat-extraction-status.json")
313327
);
314328

315-
if (!existsSync(statusPath)) {
329+
if (!statusPath) {
316330
json(context, 200, { status: "none", count: 0 });
317331
return;
318332
}
@@ -372,7 +386,9 @@ export function registerLearningsRoutes(
372386
return;
373387
}
374388

375-
const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
389+
const newRecordWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
390+
// Always write to the new canonical path; reads may fall back to legacy.
391+
const claudeWorkDir = newRecordWorkDir;
376392
const learningsDir = path.join(claudeWorkDir, ".learnings");
377393

378394
try {

apps/desktop/src/server/operations/metadata-routes.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ export function registerMetadataRoutes(
8686
throw error;
8787
}
8888

89-
const stateFile = path.join(expandedWorkDir, ".claude", "work", "state.json");
89+
const newStateFile = path.join(expandedWorkDir, ".closedloop-ai", "work", "state.json");
90+
const oldStateFile = path.join(expandedWorkDir, ".claude", "work", "state.json");
91+
const stateFile = existsSync(newStateFile) ? newStateFile : oldStateFile;
9092

9193
if (!existsSync(stateFile)) {
9294
json(context, 200, {

apps/desktop/src/server/operations/symphony-attachments.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { existsSync } from "node:fs";
21
import fs from "node:fs/promises";
32
import path from "node:path";
43
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
54
import { DirectoryNotAllowedError } from "../security.js";
6-
import { assertRepoAllowed, resolveWorktreeDir } from "./symphony-utils.js";
5+
import { assertRepoAllowed, findFirstExisting, resolveWorktreeDir } from "./symphony-utils.js";
76

87
const CONTENT_TYPES: Record<string, string> = {
98
".png": "image/png",
@@ -48,22 +47,29 @@ export function registerSymphonyAttachmentsRoutes(
4847
}
4948

5049
const worktreeDir = resolveWorktreeDir(expandedRepoPath, ticketId);
51-
const attachmentsDir = path.join(worktreeDir, ".claude", "work", "attachments");
5250
const normalizedAttachmentPath = attachmentPath
5351
.split("/")
5452
.map((segment) => decodeURIComponent(segment))
5553
.join(path.sep);
56-
const filePath = path.resolve(attachmentsDir, normalizedAttachmentPath);
57-
const resolvedAttachmentsDir = path.resolve(attachmentsDir);
58-
const allowedPrefix = resolvedAttachmentsDir.endsWith(path.sep)
59-
? resolvedAttachmentsDir
60-
: `${resolvedAttachmentsDir}${path.sep}`;
61-
if (!(filePath === resolvedAttachmentsDir || filePath.startsWith(allowedPrefix))) {
54+
55+
// Resolve both candidate absolute paths and verify neither escapes its attachments dir
56+
const newAttachmentsDir = path.resolve(path.join(worktreeDir, ".closedloop-ai", "work", "attachments"));
57+
const oldAttachmentsDir = path.resolve(path.join(worktreeDir, ".claude", "work", "attachments"));
58+
const newFilePath = path.resolve(newAttachmentsDir, normalizedAttachmentPath);
59+
const oldFilePath = path.resolve(oldAttachmentsDir, normalizedAttachmentPath);
60+
61+
const isUnderDir = (file: string, dir: string): boolean => {
62+
const prefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
63+
return file === dir || file.startsWith(prefix);
64+
};
65+
66+
if (!isUnderDir(newFilePath, newAttachmentsDir) && !isUnderDir(oldFilePath, oldAttachmentsDir)) {
6267
json(context, 403, { error: "Invalid path" });
6368
return;
6469
}
6570

66-
if (!existsSync(filePath)) {
71+
const filePath = findFirstExisting(newFilePath, oldFilePath);
72+
if (!filePath) {
6773
json(context, 404, { error: "File not found" });
6874
return;
6975
}

0 commit comments

Comments
 (0)