@litmers/cursorflow-orchestrator 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +25 -7
- package/dist/cli/clean.js +7 -6
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/index.js +5 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.js +7 -6
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/logs.js +50 -42
- package/dist/cli/logs.js.map +1 -1
- package/dist/cli/monitor.js +15 -14
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/prepare.js +37 -20
- package/dist/cli/prepare.js.map +1 -1
- package/dist/cli/resume.js +193 -40
- package/dist/cli/resume.js.map +1 -1
- package/dist/cli/run.js +3 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/signal.js +7 -7
- package/dist/cli/signal.js.map +1 -1
- package/dist/core/orchestrator.d.ts +2 -1
- package/dist/core/orchestrator.js +48 -91
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/runner.js +55 -20
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/config.js +7 -6
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/doctor.js +7 -6
- package/dist/utils/doctor.js.map +1 -1
- package/dist/utils/enhanced-logger.js +14 -11
- package/dist/utils/enhanced-logger.js.map +1 -1
- package/dist/utils/git.js +163 -10
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/log-formatter.d.ts +16 -0
- package/dist/utils/log-formatter.js +194 -0
- package/dist/utils/log-formatter.js.map +1 -0
- package/dist/utils/path.d.ts +19 -0
- package/dist/utils/path.js +77 -0
- package/dist/utils/path.js.map +1 -0
- package/dist/utils/state.d.ts +4 -1
- package/dist/utils/state.js +11 -8
- package/dist/utils/state.js.map +1 -1
- package/dist/utils/template.d.ts +14 -0
- package/dist/utils/template.js +122 -0
- package/dist/utils/template.js.map +1 -0
- package/dist/utils/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/cli/clean.ts +7 -6
- package/src/cli/index.ts +5 -1
- package/src/cli/init.ts +7 -6
- package/src/cli/logs.ts +52 -42
- package/src/cli/monitor.ts +15 -14
- package/src/cli/prepare.ts +39 -20
- package/src/cli/resume.ts +810 -626
- package/src/cli/run.ts +3 -2
- package/src/cli/signal.ts +7 -6
- package/src/core/orchestrator.ts +62 -91
- package/src/core/runner.ts +58 -20
- package/src/utils/config.ts +7 -6
- package/src/utils/doctor.ts +7 -6
- package/src/utils/enhanced-logger.ts +14 -11
- package/src/utils/git.ts +145 -11
- package/src/utils/log-formatter.ts +162 -0
- package/src/utils/path.ts +45 -0
- package/src/utils/state.ts +16 -8
- package/src/utils/template.ts +92 -0
- package/src/utils/types.ts +1 -0
- package/templates/basic.json +21 -0
package/src/cli/run.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { orchestrate } from '../core/orchestrator';
|
|
|
9
9
|
import { getLogsDir, loadConfig } from '../utils/config';
|
|
10
10
|
import { runDoctor, getDoctorStatus } from '../utils/doctor';
|
|
11
11
|
import { areCommandsInstalled, setupCommands } from './setup-commands';
|
|
12
|
+
import { safeJoin } from '../utils/path';
|
|
12
13
|
|
|
13
14
|
interface RunOptions {
|
|
14
15
|
tasksDir?: string;
|
|
@@ -90,8 +91,8 @@ async function run(args: string[]): Promise<void> {
|
|
|
90
91
|
path.isAbsolute(options.tasksDir)
|
|
91
92
|
? options.tasksDir
|
|
92
93
|
: (fs.existsSync(options.tasksDir)
|
|
93
|
-
? path.resolve(process.cwd(), options.tasksDir)
|
|
94
|
-
:
|
|
94
|
+
? path.resolve(process.cwd(), options.tasksDir) // nosemgrep
|
|
95
|
+
: safeJoin(config.projectRoot, options.tasksDir));
|
|
95
96
|
|
|
96
97
|
if (!fs.existsSync(tasksDir)) {
|
|
97
98
|
throw new Error(`Tasks directory not found: ${tasksDir}`);
|
package/src/cli/signal.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as fs from 'fs';
|
|
|
9
9
|
import * as logger from '../utils/logger';
|
|
10
10
|
import { loadConfig, getLogsDir } from '../utils/config';
|
|
11
11
|
import { appendLog, createConversationEntry } from '../utils/state';
|
|
12
|
+
import { safeJoin } from '../utils/path';
|
|
12
13
|
|
|
13
14
|
interface SignalOptions {
|
|
14
15
|
lane: string | null;
|
|
@@ -51,7 +52,7 @@ function parseArgs(args: string[]): SignalOptions {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
function findLatestRunDir(logsDir: string): string | null {
|
|
54
|
-
const runsDir =
|
|
55
|
+
const runsDir = safeJoin(logsDir, 'runs');
|
|
55
56
|
if (!fs.existsSync(runsDir)) return null;
|
|
56
57
|
|
|
57
58
|
const runs = fs.readdirSync(runsDir)
|
|
@@ -59,7 +60,7 @@ function findLatestRunDir(logsDir: string): string | null {
|
|
|
59
60
|
.sort()
|
|
60
61
|
.reverse();
|
|
61
62
|
|
|
62
|
-
return runs.length > 0 ?
|
|
63
|
+
return runs.length > 0 ? safeJoin(runsDir, runs[0]!) : null;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
async function signal(args: string[]): Promise<void> {
|
|
@@ -86,14 +87,14 @@ async function signal(args: string[]): Promise<void> {
|
|
|
86
87
|
throw new Error(`Run directory not found: ${runDir || 'latest'}`);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
const laneDir =
|
|
90
|
+
const laneDir = safeJoin(runDir, 'lanes', options.lane);
|
|
90
91
|
if (!fs.existsSync(laneDir)) {
|
|
91
92
|
throw new Error(`Lane directory not found: ${laneDir}`);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
// Case 1: Timeout update
|
|
95
96
|
if (options.timeout !== null) {
|
|
96
|
-
const timeoutPath =
|
|
97
|
+
const timeoutPath = safeJoin(laneDir, 'timeout.txt');
|
|
97
98
|
fs.writeFileSync(timeoutPath, String(options.timeout));
|
|
98
99
|
logger.success(`Timeout update signal sent to ${options.lane}: ${options.timeout}ms`);
|
|
99
100
|
return;
|
|
@@ -101,8 +102,8 @@ async function signal(args: string[]): Promise<void> {
|
|
|
101
102
|
|
|
102
103
|
// Case 2: Intervention message
|
|
103
104
|
if (options.message) {
|
|
104
|
-
const interventionPath =
|
|
105
|
-
const convoPath =
|
|
105
|
+
const interventionPath = safeJoin(laneDir, 'intervention.txt');
|
|
106
|
+
const convoPath = safeJoin(laneDir, 'conversation.jsonl');
|
|
106
107
|
|
|
107
108
|
logger.info(`Sending signal to lane: ${options.lane}`);
|
|
108
109
|
logger.info(`Message: "${options.message}"`);
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -9,13 +9,14 @@ import * as path from 'path';
|
|
|
9
9
|
import { spawn, ChildProcess } from 'child_process';
|
|
10
10
|
|
|
11
11
|
import * as logger from '../utils/logger';
|
|
12
|
-
import { loadState } from '../utils/state';
|
|
12
|
+
import { loadState, saveState, createLaneState } from '../utils/state';
|
|
13
13
|
import { LaneState, RunnerConfig, WebhookConfig, DependencyRequestPlan, EnhancedLogConfig } from '../utils/types';
|
|
14
14
|
import { events } from '../utils/events';
|
|
15
15
|
import { registerWebhooks } from '../utils/webhook';
|
|
16
16
|
import { loadConfig, getLogsDir } from '../utils/config';
|
|
17
17
|
import * as git from '../utils/git';
|
|
18
18
|
import { execSync } from 'child_process';
|
|
19
|
+
import { safeJoin } from '../utils/path';
|
|
19
20
|
import {
|
|
20
21
|
EnhancedLogManager,
|
|
21
22
|
createLogManager,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
ParsedMessage,
|
|
24
25
|
stripAnsi
|
|
25
26
|
} from '../utils/enhanced-logger';
|
|
27
|
+
import { formatMessageForConsole } from '../utils/log-formatter';
|
|
26
28
|
|
|
27
29
|
export interface LaneInfo {
|
|
28
30
|
name: string;
|
|
@@ -47,6 +49,7 @@ export function spawnLane({
|
|
|
47
49
|
executor,
|
|
48
50
|
startIndex = 0,
|
|
49
51
|
pipelineBranch,
|
|
52
|
+
worktreeDir,
|
|
50
53
|
enhancedLogConfig,
|
|
51
54
|
noGit = false,
|
|
52
55
|
}: {
|
|
@@ -56,6 +59,7 @@ export function spawnLane({
|
|
|
56
59
|
executor: string;
|
|
57
60
|
startIndex?: number;
|
|
58
61
|
pipelineBranch?: string;
|
|
62
|
+
worktreeDir?: string;
|
|
59
63
|
enhancedLogConfig?: Partial<EnhancedLogConfig>;
|
|
60
64
|
noGit?: boolean;
|
|
61
65
|
}): SpawnLaneResult {
|
|
@@ -75,6 +79,10 @@ export function spawnLane({
|
|
|
75
79
|
if (pipelineBranch) {
|
|
76
80
|
args.push('--pipeline-branch', pipelineBranch);
|
|
77
81
|
}
|
|
82
|
+
|
|
83
|
+
if (worktreeDir) {
|
|
84
|
+
args.push('--worktree-dir', worktreeDir);
|
|
85
|
+
}
|
|
78
86
|
|
|
79
87
|
if (noGit) {
|
|
80
88
|
args.push('--no-git');
|
|
@@ -94,85 +102,11 @@ export function spawnLane({
|
|
|
94
102
|
if (logConfig.enabled) {
|
|
95
103
|
// Create callback for clean console output
|
|
96
104
|
const onParsedMessage = (msg: ParsedMessage) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
let content = msg.content;
|
|
103
|
-
|
|
104
|
-
switch (msg.type) {
|
|
105
|
-
case 'user':
|
|
106
|
-
prefix = `${logger.COLORS.cyan}🧑 USER${logger.COLORS.reset}`;
|
|
107
|
-
// No truncation for user prompt to ensure full command visibility
|
|
108
|
-
content = content.replace(/\n/g, ' ');
|
|
109
|
-
break;
|
|
110
|
-
case 'assistant':
|
|
111
|
-
prefix = `${logger.COLORS.green}🤖 ASST${logger.COLORS.reset}`;
|
|
112
|
-
break;
|
|
113
|
-
case 'tool':
|
|
114
|
-
prefix = `${logger.COLORS.yellow}🔧 TOOL${logger.COLORS.reset}`;
|
|
115
|
-
// Simplify tool call: [Tool: read_file] {"target_file":"..."} -> read_file(target_file: ...)
|
|
116
|
-
const toolMatch = content.match(/\[Tool: ([^\]]+)\] (.*)/);
|
|
117
|
-
if (toolMatch) {
|
|
118
|
-
const [, name, args] = toolMatch;
|
|
119
|
-
try {
|
|
120
|
-
const parsedArgs = JSON.parse(args!);
|
|
121
|
-
let argStr = '';
|
|
122
|
-
|
|
123
|
-
if (name === 'read_file' && parsedArgs.target_file) {
|
|
124
|
-
argStr = parsedArgs.target_file;
|
|
125
|
-
} else if (name === 'run_terminal_cmd' && parsedArgs.command) {
|
|
126
|
-
argStr = parsedArgs.command;
|
|
127
|
-
} else if (name === 'write' && parsedArgs.file_path) {
|
|
128
|
-
argStr = parsedArgs.file_path;
|
|
129
|
-
} else if (name === 'search_replace' && parsedArgs.file_path) {
|
|
130
|
-
argStr = parsedArgs.file_path;
|
|
131
|
-
} else {
|
|
132
|
-
// Generic summary for other tools
|
|
133
|
-
const keys = Object.keys(parsedArgs);
|
|
134
|
-
if (keys.length > 0) {
|
|
135
|
-
argStr = String(parsedArgs[keys[0]]).substring(0, 50);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}(${argStr})`;
|
|
139
|
-
} catch {
|
|
140
|
-
content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}: ${args}`;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
break;
|
|
144
|
-
case 'tool_result':
|
|
145
|
-
prefix = `${logger.COLORS.gray}📄 RESL${logger.COLORS.reset}`;
|
|
146
|
-
// Simplify tool result: [Tool Result: read_file] ... -> read_file OK
|
|
147
|
-
const resMatch = content.match(/\[Tool Result: ([^\]]+)\]/);
|
|
148
|
-
content = resMatch ? `${resMatch[1]} OK` : 'result';
|
|
149
|
-
break;
|
|
150
|
-
case 'result':
|
|
151
|
-
prefix = `${logger.COLORS.green}✅ DONE${logger.COLORS.reset}`;
|
|
152
|
-
break;
|
|
153
|
-
case 'system':
|
|
154
|
-
prefix = `${logger.COLORS.gray}⚙️ SYS${logger.COLORS.reset}`;
|
|
155
|
-
break;
|
|
156
|
-
case 'thinking':
|
|
157
|
-
prefix = `${logger.COLORS.gray}🤔 THNK${logger.COLORS.reset}`;
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (prefix) {
|
|
162
|
-
const lines = content.split('\n');
|
|
163
|
-
const tsPrefix = `${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneLabel}${logger.COLORS.reset}`;
|
|
164
|
-
|
|
165
|
-
if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'result' || msg.type === 'thinking') {
|
|
166
|
-
const header = `${prefix} ┌${'─'.repeat(60)}`;
|
|
167
|
-
process.stdout.write(`${tsPrefix} ${header}\n`);
|
|
168
|
-
for (const line of lines) {
|
|
169
|
-
process.stdout.write(`${tsPrefix} ${' '.repeat(stripAnsi(prefix).length)} │ ${line}\n`);
|
|
170
|
-
}
|
|
171
|
-
process.stdout.write(`${tsPrefix} ${' '.repeat(stripAnsi(prefix).length)} └${'─'.repeat(60)}\n`);
|
|
172
|
-
} else {
|
|
173
|
-
process.stdout.write(`${tsPrefix} ${prefix} ${content}\n`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
105
|
+
const formatted = formatMessageForConsole(msg, {
|
|
106
|
+
laneLabel: `[${laneName}]`,
|
|
107
|
+
includeTimestamp: true
|
|
108
|
+
});
|
|
109
|
+
process.stdout.write(formatted + '\n');
|
|
176
110
|
};
|
|
177
111
|
|
|
178
112
|
logManager = createLogManager(laneRunDir, laneName, logConfig, onParsedMessage);
|
|
@@ -242,7 +176,7 @@ export function spawnLane({
|
|
|
242
176
|
});
|
|
243
177
|
} else {
|
|
244
178
|
// Fallback to simple file logging
|
|
245
|
-
logPath =
|
|
179
|
+
logPath = safeJoin(laneRunDir, 'terminal.log');
|
|
246
180
|
const logFd = fs.openSync(logPath, 'a');
|
|
247
181
|
|
|
248
182
|
child = spawn('node', args, {
|
|
@@ -289,7 +223,7 @@ export function listLaneFiles(tasksDir: string): LaneInfo[] {
|
|
|
289
223
|
.filter(f => f.endsWith('.json'))
|
|
290
224
|
.sort()
|
|
291
225
|
.map(f => {
|
|
292
|
-
const filePath =
|
|
226
|
+
const filePath = safeJoin(tasksDir, f);
|
|
293
227
|
const name = path.basename(f, '.json');
|
|
294
228
|
let dependsOn: string[] = [];
|
|
295
229
|
|
|
@@ -316,7 +250,7 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
|
|
|
316
250
|
const dir = laneRunDirs[lane.name];
|
|
317
251
|
if (!dir) return { lane: lane.name, status: '(unknown)', task: '-' };
|
|
318
252
|
|
|
319
|
-
const statePath =
|
|
253
|
+
const statePath = safeJoin(dir, 'state.json');
|
|
320
254
|
const state = loadState<LaneState>(statePath);
|
|
321
255
|
|
|
322
256
|
if (!state) {
|
|
@@ -364,9 +298,9 @@ async function resolveAllDependencies(
|
|
|
364
298
|
|
|
365
299
|
// 2. Setup a temporary worktree for resolution if needed, or use the first available one
|
|
366
300
|
const firstLaneName = Array.from(blockedLanes.keys())[0]!;
|
|
367
|
-
const statePath =
|
|
301
|
+
const statePath = safeJoin(laneRunDirs[firstLaneName]!, 'state.json');
|
|
368
302
|
const state = loadState<LaneState>(statePath);
|
|
369
|
-
const worktreeDir = state?.worktreeDir ||
|
|
303
|
+
const worktreeDir = state?.worktreeDir || safeJoin(runRoot, 'resolution-worktree');
|
|
370
304
|
|
|
371
305
|
if (!fs.existsSync(worktreeDir)) {
|
|
372
306
|
logger.info(`Creating resolution worktree at ${worktreeDir}`);
|
|
@@ -405,7 +339,7 @@ async function resolveAllDependencies(
|
|
|
405
339
|
const laneDir = laneRunDirs[lane.name];
|
|
406
340
|
if (!laneDir) continue;
|
|
407
341
|
|
|
408
|
-
const laneState = loadState<LaneState>(
|
|
342
|
+
const laneState = loadState<LaneState>(safeJoin(laneDir, 'state.json'));
|
|
409
343
|
if (!laneState || laneState.status === 'completed' || laneState.status === 'failed') continue;
|
|
410
344
|
|
|
411
345
|
// Merge pipelineBranch into the lane's current task branch
|
|
@@ -465,8 +399,8 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
465
399
|
const runId = `run-${Date.now()}`;
|
|
466
400
|
// Use absolute path for runRoot to avoid issues with subfolders
|
|
467
401
|
const runRoot = options.runDir
|
|
468
|
-
? (path.isAbsolute(options.runDir) ? options.runDir : path.resolve(process.cwd(), options.runDir))
|
|
469
|
-
:
|
|
402
|
+
? (path.isAbsolute(options.runDir) ? options.runDir : path.resolve(process.cwd(), options.runDir)) // nosemgrep
|
|
403
|
+
: safeJoin(logsDir, 'runs', runId);
|
|
470
404
|
|
|
471
405
|
fs.mkdirSync(runRoot, { recursive: true });
|
|
472
406
|
|
|
@@ -499,9 +433,45 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
499
433
|
}
|
|
500
434
|
|
|
501
435
|
const laneRunDirs: Record<string, string> = {};
|
|
436
|
+
const laneWorktreeDirs: Record<string, string> = {};
|
|
437
|
+
const repoRoot = git.getRepoRoot();
|
|
438
|
+
|
|
502
439
|
for (const lane of lanes) {
|
|
503
|
-
laneRunDirs[lane.name] =
|
|
504
|
-
fs.mkdirSync(laneRunDirs[lane.name]
|
|
440
|
+
laneRunDirs[lane.name] = safeJoin(runRoot, 'lanes', lane.name);
|
|
441
|
+
fs.mkdirSync(laneRunDirs[lane.name]!, { recursive: true });
|
|
442
|
+
|
|
443
|
+
// Create initial state for ALL lanes so resume can find them even if they didn't start
|
|
444
|
+
try {
|
|
445
|
+
const taskConfig = JSON.parse(fs.readFileSync(lane.path, 'utf8')) as RunnerConfig;
|
|
446
|
+
|
|
447
|
+
// Calculate unique branch and worktree for this lane
|
|
448
|
+
const lanePipelineBranch = `${pipelineBranch}/${lane.name}`;
|
|
449
|
+
|
|
450
|
+
// Use a flat worktree directory name to avoid race conditions in parent directory creation
|
|
451
|
+
// repoRoot/_cursorflow/worktrees/cursorflow-run-xxx-lane-name
|
|
452
|
+
const laneWorktreeDir = safeJoin(
|
|
453
|
+
repoRoot,
|
|
454
|
+
taskConfig.worktreeRoot || '_cursorflow/worktrees',
|
|
455
|
+
lanePipelineBranch.replace(/\//g, '-')
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// Ensure the parent directory exists before spawning the runner
|
|
459
|
+
// to avoid race conditions in git worktree add or fs operations
|
|
460
|
+
const worktreeParent = path.dirname(laneWorktreeDir);
|
|
461
|
+
if (!fs.existsSync(worktreeParent)) {
|
|
462
|
+
fs.mkdirSync(worktreeParent, { recursive: true });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
laneWorktreeDirs[lane.name] = laneWorktreeDir;
|
|
466
|
+
|
|
467
|
+
const initialState = createLaneState(lane.name, taskConfig, lane.path, {
|
|
468
|
+
pipelineBranch: lanePipelineBranch,
|
|
469
|
+
worktreeDir: laneWorktreeDir
|
|
470
|
+
});
|
|
471
|
+
saveState(safeJoin(laneRunDirs[lane.name]!, 'state.json'), initialState);
|
|
472
|
+
} catch (e) {
|
|
473
|
+
logger.warn(`Failed to create initial state for lane ${lane.name}: ${e}`);
|
|
474
|
+
}
|
|
505
475
|
}
|
|
506
476
|
|
|
507
477
|
logger.section('🧭 Starting Orchestration');
|
|
@@ -575,6 +545,7 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
575
545
|
executor: options.executor || 'cursor-agent',
|
|
576
546
|
startIndex: lane.startIndex,
|
|
577
547
|
pipelineBranch: `${pipelineBranch}/${lane.name}`,
|
|
548
|
+
worktreeDir: laneWorktreeDirs[lane.name],
|
|
578
549
|
enhancedLogConfig: options.enhancedLogging,
|
|
579
550
|
noGit: options.noGit,
|
|
580
551
|
});
|
|
@@ -607,7 +578,7 @@ export async function orchestrate(tasksDir: string, options: {
|
|
|
607
578
|
});
|
|
608
579
|
} else if (finished.code === 2) {
|
|
609
580
|
// Blocked by dependency
|
|
610
|
-
const statePath =
|
|
581
|
+
const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
|
|
611
582
|
const state = loadState<LaneState>(statePath);
|
|
612
583
|
|
|
613
584
|
if (state && state.dependencyRequest) {
|
package/src/core/runner.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { events } from '../utils/events';
|
|
|
16
16
|
import { loadConfig } from '../utils/config';
|
|
17
17
|
import { registerWebhooks } from '../utils/webhook';
|
|
18
18
|
import { runReviewLoop } from './reviewer';
|
|
19
|
+
import { safeJoin } from '../utils/path';
|
|
19
20
|
import {
|
|
20
21
|
RunnerConfig,
|
|
21
22
|
Task,
|
|
@@ -239,7 +240,7 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
239
240
|
// Save PID to state if possible (avoid TOCTOU by reading directly)
|
|
240
241
|
if (child.pid && signalDir) {
|
|
241
242
|
try {
|
|
242
|
-
const statePath =
|
|
243
|
+
const statePath = safeJoin(signalDir, 'state.json');
|
|
243
244
|
// Read directly without existence check to avoid race condition
|
|
244
245
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
245
246
|
state.pid = child.pid;
|
|
@@ -474,7 +475,7 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
|
|
|
474
475
|
}
|
|
475
476
|
|
|
476
477
|
for (const file of targets) {
|
|
477
|
-
const filePath =
|
|
478
|
+
const filePath = safeJoin(worktreeDir, file);
|
|
478
479
|
if (!fs.existsSync(filePath)) continue;
|
|
479
480
|
|
|
480
481
|
try {
|
|
@@ -507,7 +508,7 @@ export async function waitForTaskDependencies(deps: string[], runDir: string): P
|
|
|
507
508
|
continue;
|
|
508
509
|
}
|
|
509
510
|
|
|
510
|
-
const depStatePath =
|
|
511
|
+
const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
|
|
511
512
|
if (fs.existsSync(depStatePath)) {
|
|
512
513
|
try {
|
|
513
514
|
const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
|
|
@@ -540,7 +541,7 @@ export async function mergeDependencyBranches(deps: string[], runDir: string, wo
|
|
|
540
541
|
const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
|
|
541
542
|
|
|
542
543
|
for (const laneName of lanesToMerge) {
|
|
543
|
-
const depStatePath =
|
|
544
|
+
const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
|
|
544
545
|
if (!fs.existsSync(depStatePath)) continue;
|
|
545
546
|
|
|
546
547
|
try {
|
|
@@ -589,7 +590,7 @@ export async function runTask({
|
|
|
589
590
|
}): Promise<TaskExecutionResult> {
|
|
590
591
|
const model = task.model || config.model || 'sonnet-4.5';
|
|
591
592
|
const timeout = task.timeout || config.timeout;
|
|
592
|
-
const convoPath =
|
|
593
|
+
const convoPath = safeJoin(runDir, 'conversation.jsonl');
|
|
593
594
|
|
|
594
595
|
logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
|
|
595
596
|
logger.info(`Model: ${model}`);
|
|
@@ -781,7 +782,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
781
782
|
const repoRoot = noGit ? process.cwd() : git.getRepoRoot();
|
|
782
783
|
|
|
783
784
|
// Load existing state if resuming
|
|
784
|
-
const statePath =
|
|
785
|
+
const statePath = safeJoin(runDir, 'state.json');
|
|
785
786
|
let state: LaneState | null = null;
|
|
786
787
|
|
|
787
788
|
if (fs.existsSync(statePath)) {
|
|
@@ -794,10 +795,12 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
794
795
|
|
|
795
796
|
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
|
796
797
|
const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
|
|
798
|
+
|
|
797
799
|
// In noGit mode, use a simple local directory instead of worktree
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
800
|
+
// Flatten the path by replacing slashes with hyphens to avoid race conditions in parent directory creation
|
|
801
|
+
const worktreeDir = state?.worktreeDir || config.worktreeDir || (noGit
|
|
802
|
+
? safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
|
|
803
|
+
: safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/worktrees', pipelineBranch.replace(/\//g, '-')));
|
|
801
804
|
|
|
802
805
|
if (startIndex === 0) {
|
|
803
806
|
logger.section('🚀 Starting Pipeline');
|
|
@@ -816,10 +819,37 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
816
819
|
logger.info(`Creating work directory: ${worktreeDir}`);
|
|
817
820
|
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
818
821
|
} else {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
822
|
+
// Use a simple retry mechanism for Git worktree creation to handle potential race conditions
|
|
823
|
+
let retries = 3;
|
|
824
|
+
let lastError: Error | null = null;
|
|
825
|
+
|
|
826
|
+
while (retries > 0) {
|
|
827
|
+
try {
|
|
828
|
+
// Ensure parent directory exists before calling git worktree
|
|
829
|
+
const worktreeParent = path.dirname(worktreeDir);
|
|
830
|
+
if (!fs.existsSync(worktreeParent)) {
|
|
831
|
+
fs.mkdirSync(worktreeParent, { recursive: true });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
git.createWorktree(worktreeDir, pipelineBranch, {
|
|
835
|
+
baseBranch: config.baseBranch || 'main',
|
|
836
|
+
cwd: repoRoot,
|
|
837
|
+
});
|
|
838
|
+
break; // Success
|
|
839
|
+
} catch (e: any) {
|
|
840
|
+
lastError = e;
|
|
841
|
+
retries--;
|
|
842
|
+
if (retries > 0) {
|
|
843
|
+
const delay = Math.floor(Math.random() * 1000) + 500;
|
|
844
|
+
logger.warn(`Worktree creation failed, retrying in ${delay}ms... (${retries} retries left)`);
|
|
845
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (retries === 0 && lastError) {
|
|
851
|
+
throw new Error(`Failed to create Git worktree after retries: ${lastError.message}`);
|
|
852
|
+
}
|
|
823
853
|
}
|
|
824
854
|
} else if (!noGit) {
|
|
825
855
|
// If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
|
|
@@ -858,6 +888,9 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
858
888
|
state.status = 'running';
|
|
859
889
|
state.error = null;
|
|
860
890
|
state.dependencyRequest = null;
|
|
891
|
+
state.pipelineBranch = pipelineBranch;
|
|
892
|
+
state.worktreeDir = worktreeDir;
|
|
893
|
+
state.label = state.label || pipelineBranch;
|
|
861
894
|
state.dependsOn = config.dependsOn || [];
|
|
862
895
|
state.completedTasks = state.completedTasks || [];
|
|
863
896
|
}
|
|
@@ -872,8 +905,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
872
905
|
const lanesRoot = path.dirname(runDir);
|
|
873
906
|
|
|
874
907
|
for (const depName of config.dependsOn) {
|
|
875
|
-
const depRunDir = path.join(lanesRoot, depName);
|
|
876
|
-
const depStatePath = path.join(depRunDir, 'state.json');
|
|
908
|
+
const depRunDir = path.join(lanesRoot, depName); // nosemgrep
|
|
909
|
+
const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
|
|
877
910
|
|
|
878
911
|
if (!fs.existsSync(depStatePath)) {
|
|
879
912
|
logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
|
|
@@ -919,8 +952,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
919
952
|
const lanesRoot = path.dirname(runDir);
|
|
920
953
|
|
|
921
954
|
for (const depName of config.dependsOn) {
|
|
922
|
-
const depRunDir =
|
|
923
|
-
const depStatePath =
|
|
955
|
+
const depRunDir = safeJoin(lanesRoot, depName);
|
|
956
|
+
const depStatePath = safeJoin(depRunDir, 'state.json');
|
|
924
957
|
|
|
925
958
|
if (!fs.existsSync(depStatePath)) {
|
|
926
959
|
continue;
|
|
@@ -939,8 +972,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
939
972
|
for (const entry of entries) {
|
|
940
973
|
if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
|
|
941
974
|
|
|
942
|
-
const srcPath =
|
|
943
|
-
const destPath =
|
|
975
|
+
const srcPath = safeJoin(src, entry.name);
|
|
976
|
+
const destPath = safeJoin(dest, entry.name);
|
|
944
977
|
|
|
945
978
|
if (entry.isDirectory()) {
|
|
946
979
|
copyFiles(srcPath, destPath);
|
|
@@ -1072,7 +1105,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
1072
1105
|
|
|
1073
1106
|
if (entry.isDirectory()) {
|
|
1074
1107
|
stats.dirs++;
|
|
1075
|
-
const sub = getFileSummary(
|
|
1108
|
+
const sub = getFileSummary(safeJoin(dir, entry.name));
|
|
1076
1109
|
stats.files += sub.files;
|
|
1077
1110
|
stats.dirs += sub.dirs;
|
|
1078
1111
|
} else {
|
|
@@ -1114,11 +1147,13 @@ if (require.main === module) {
|
|
|
1114
1147
|
const runDirIdx = args.indexOf('--run-dir');
|
|
1115
1148
|
const startIdxIdx = args.indexOf('--start-index');
|
|
1116
1149
|
const pipelineBranchIdx = args.indexOf('--pipeline-branch');
|
|
1150
|
+
const worktreeDirIdx = args.indexOf('--worktree-dir');
|
|
1117
1151
|
const noGit = args.includes('--no-git');
|
|
1118
1152
|
|
|
1119
1153
|
const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
|
|
1120
1154
|
const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
|
|
1121
1155
|
const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
|
|
1156
|
+
const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
|
|
1122
1157
|
|
|
1123
1158
|
// Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
|
|
1124
1159
|
const parts = runDir.split(path.sep);
|
|
@@ -1150,6 +1185,9 @@ if (require.main === module) {
|
|
|
1150
1185
|
if (forcedPipelineBranch) {
|
|
1151
1186
|
config.pipelineBranch = forcedPipelineBranch;
|
|
1152
1187
|
}
|
|
1188
|
+
if (forcedWorktreeDir) {
|
|
1189
|
+
config.worktreeDir = forcedWorktreeDir;
|
|
1190
|
+
}
|
|
1153
1191
|
} catch (error: any) {
|
|
1154
1192
|
console.error(`Failed to load tasks file: ${error.message}`);
|
|
1155
1193
|
process.exit(1);
|
package/src/utils/config.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import { CursorFlowConfig } from './types';
|
|
10
|
+
import { safeJoin } from './path';
|
|
10
11
|
export { CursorFlowConfig };
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -16,8 +17,8 @@ export function findProjectRoot(cwd = process.cwd()): string {
|
|
|
16
17
|
let current = cwd;
|
|
17
18
|
|
|
18
19
|
while (current !== path.parse(current).root) {
|
|
19
|
-
const packagePath =
|
|
20
|
-
const configPath =
|
|
20
|
+
const packagePath = safeJoin(current, 'package.json');
|
|
21
|
+
const configPath = safeJoin(current, 'cursorflow.config.js');
|
|
21
22
|
|
|
22
23
|
if (fs.existsSync(packagePath) || fs.existsSync(configPath)) {
|
|
23
24
|
return current;
|
|
@@ -36,7 +37,7 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
|
|
|
36
37
|
projectRoot = findProjectRoot();
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const configPath =
|
|
40
|
+
const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
|
|
40
41
|
|
|
41
42
|
// Default configuration
|
|
42
43
|
const defaults: CursorFlowConfig = {
|
|
@@ -114,14 +115,14 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
|
|
|
114
115
|
* Get absolute path for tasks directory
|
|
115
116
|
*/
|
|
116
117
|
export function getTasksDir(config: CursorFlowConfig): string {
|
|
117
|
-
return
|
|
118
|
+
return safeJoin(config.projectRoot, config.tasksDir);
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* Get absolute path for logs directory
|
|
122
123
|
*/
|
|
123
124
|
export function getLogsDir(config: CursorFlowConfig): string {
|
|
124
|
-
return
|
|
125
|
+
return safeJoin(config.projectRoot, config.logsDir);
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -157,7 +158,7 @@ export function validateConfig(config: CursorFlowConfig): boolean {
|
|
|
157
158
|
* Create default config file
|
|
158
159
|
*/
|
|
159
160
|
export function createDefaultConfig(projectRoot: string, force = false): string {
|
|
160
|
-
const configPath =
|
|
161
|
+
const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
|
|
161
162
|
|
|
162
163
|
const template = `module.exports = {
|
|
163
164
|
// Directory configuration
|
package/src/utils/doctor.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as path from 'path';
|
|
|
18
18
|
import * as git from './git';
|
|
19
19
|
import { checkCursorAgentInstalled, checkCursorAuth } from './cursor-agent';
|
|
20
20
|
import { areCommandsInstalled } from '../cli/setup-commands';
|
|
21
|
+
import { safeJoin } from './path';
|
|
21
22
|
|
|
22
23
|
export type DoctorSeverity = 'error' | 'warn';
|
|
23
24
|
|
|
@@ -149,7 +150,7 @@ function readLaneJsonFiles(tasksDir: string): { path: string; json: any; fileNam
|
|
|
149
150
|
.readdirSync(tasksDir)
|
|
150
151
|
.filter(f => f.endsWith('.json'))
|
|
151
152
|
.sort()
|
|
152
|
-
.map(f =>
|
|
153
|
+
.map(f => safeJoin(tasksDir, f));
|
|
153
154
|
|
|
154
155
|
return files.map(p => {
|
|
155
156
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -428,7 +429,7 @@ function checkDiskSpace(dir: string): { ok: boolean; freeBytes?: number; error?:
|
|
|
428
429
|
const { spawnSync } = require('child_process');
|
|
429
430
|
try {
|
|
430
431
|
// Validate and normalize the directory path to prevent command injection
|
|
431
|
-
const safePath = path.resolve(dir);
|
|
432
|
+
const safePath = path.resolve(dir); // nosemgrep
|
|
432
433
|
|
|
433
434
|
// Use spawnSync instead of execSync to avoid shell interpolation vulnerabilities
|
|
434
435
|
// df -B1 returns bytes. We look for the line corresponding to our directory.
|
|
@@ -612,7 +613,7 @@ function validateBranchNames(
|
|
|
612
613
|
const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
|
|
613
614
|
|
|
614
615
|
export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
|
|
615
|
-
const statusPath =
|
|
616
|
+
const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
|
|
616
617
|
const statusDir = path.dirname(statusPath);
|
|
617
618
|
|
|
618
619
|
if (!fs.existsSync(statusDir)) {
|
|
@@ -630,7 +631,7 @@ export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
|
|
|
630
631
|
}
|
|
631
632
|
|
|
632
633
|
export function getDoctorStatus(repoRoot: string): { lastRun: number; ok: boolean; issueCount: number } | null {
|
|
633
|
-
const statusPath =
|
|
634
|
+
const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
|
|
634
635
|
if (!fs.existsSync(statusPath)) return null;
|
|
635
636
|
|
|
636
637
|
try {
|
|
@@ -830,7 +831,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
830
831
|
});
|
|
831
832
|
} else {
|
|
832
833
|
// Advanced check: .gitignore check for worktrees
|
|
833
|
-
const gitignorePath =
|
|
834
|
+
const gitignorePath = safeJoin(gitCwd, '.gitignore');
|
|
834
835
|
const worktreeDirName = '_cursorflow'; // Default directory name
|
|
835
836
|
if (fs.existsSync(gitignorePath)) {
|
|
836
837
|
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
@@ -853,7 +854,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
853
854
|
if (options.tasksDir) {
|
|
854
855
|
const tasksDirAbs = path.isAbsolute(options.tasksDir)
|
|
855
856
|
? options.tasksDir
|
|
856
|
-
:
|
|
857
|
+
: safeJoin(cwd, options.tasksDir);
|
|
857
858
|
context.tasksDir = tasksDirAbs;
|
|
858
859
|
|
|
859
860
|
if (!fs.existsSync(tasksDirAbs)) {
|