@litmers/cursorflow-orchestrator 0.1.15 → 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 +23 -1
- package/README.md +26 -7
- package/commands/cursorflow-run.md +2 -0
- package/commands/cursorflow-triggers.md +250 -0
- package/dist/cli/clean.js +8 -7
- 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 +20 -14
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/logs.js +64 -47
- package/dist/cli/logs.js.map +1 -1
- package/dist/cli/monitor.js +27 -17
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/prepare.js +73 -33
- 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 +54 -93
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/reviewer.d.ts +6 -4
- package/dist/core/reviewer.js +7 -5
- package/dist/core/reviewer.js.map +1 -1
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +219 -32
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/config.js +20 -10
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/doctor.js +35 -7
- package/dist/utils/doctor.js.map +1 -1
- package/dist/utils/enhanced-logger.d.ts +2 -2
- package/dist/utils/enhanced-logger.js +114 -43
- 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/repro-thinking-logs.d.ts +1 -0
- package/dist/utils/repro-thinking-logs.js +80 -0
- package/dist/utils/repro-thinking-logs.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 +13 -0
- package/dist/utils/webhook.js +3 -0
- package/dist/utils/webhook.js.map +1 -1
- package/package.json +4 -2
- package/scripts/ai-security-check.js +3 -0
- package/scripts/local-security-gate.sh +9 -1
- package/scripts/verify-and-fix.sh +37 -0
- package/src/cli/clean.ts +8 -7
- package/src/cli/index.ts +5 -1
- package/src/cli/init.ts +19 -15
- package/src/cli/logs.ts +67 -47
- package/src/cli/monitor.ts +28 -18
- package/src/cli/prepare.ts +75 -35
- 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 +68 -93
- package/src/core/reviewer.ts +14 -9
- package/src/core/runner.ts +229 -33
- package/src/utils/config.ts +19 -11
- package/src/utils/doctor.ts +38 -7
- package/src/utils/enhanced-logger.ts +117 -49
- 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/repro-thinking-logs.ts +54 -0
- package/src/utils/state.ts +16 -8
- package/src/utils/template.ts +92 -0
- package/src/utils/types.ts +13 -0
- package/src/utils/webhook.ts +3 -0
- package/templates/basic.json +21 -0
- package/scripts/simple-logging-test.sh +0 -97
- package/scripts/test-real-cursor-lifecycle.sh +0 -289
- package/scripts/test-real-logging.sh +0 -289
- package/scripts/test-streaming-multi-task.sh +0 -247
package/src/core/runner.ts
CHANGED
|
@@ -15,7 +15,8 @@ import { saveState, appendLog, createConversationEntry } from '../utils/state';
|
|
|
15
15
|
import { events } from '../utils/events';
|
|
16
16
|
import { loadConfig } from '../utils/config';
|
|
17
17
|
import { registerWebhooks } from '../utils/webhook';
|
|
18
|
-
import {
|
|
18
|
+
import { runReviewLoop } from './reviewer';
|
|
19
|
+
import { safeJoin } from '../utils/path';
|
|
19
20
|
import {
|
|
20
21
|
RunnerConfig,
|
|
21
22
|
Task,
|
|
@@ -191,6 +192,8 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
191
192
|
const format = outputFormat || 'stream-json';
|
|
192
193
|
const args = [
|
|
193
194
|
'--print',
|
|
195
|
+
'--force',
|
|
196
|
+
'--approve-mcps',
|
|
194
197
|
'--output-format', format,
|
|
195
198
|
'--workspace', workspaceDir,
|
|
196
199
|
...(model ? ['--model', model] : []),
|
|
@@ -232,17 +235,18 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
232
235
|
env: childEnv,
|
|
233
236
|
});
|
|
234
237
|
|
|
235
|
-
|
|
238
|
+
logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
|
|
239
|
+
|
|
240
|
+
// Save PID to state if possible (avoid TOCTOU by reading directly)
|
|
236
241
|
if (child.pid && signalDir) {
|
|
237
242
|
try {
|
|
238
|
-
const statePath =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Best effort
|
|
243
|
+
const statePath = safeJoin(signalDir, 'state.json');
|
|
244
|
+
// Read directly without existence check to avoid race condition
|
|
245
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
246
|
+
state.pid = child.pid;
|
|
247
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
248
|
+
} catch {
|
|
249
|
+
// Best effort - file may not exist yet
|
|
246
250
|
}
|
|
247
251
|
}
|
|
248
252
|
|
|
@@ -471,7 +475,7 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
|
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
for (const file of targets) {
|
|
474
|
-
const filePath =
|
|
478
|
+
const filePath = safeJoin(worktreeDir, file);
|
|
475
479
|
if (!fs.existsSync(filePath)) continue;
|
|
476
480
|
|
|
477
481
|
try {
|
|
@@ -484,6 +488,82 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
|
|
|
484
488
|
}
|
|
485
489
|
}
|
|
486
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Wait for task-level dependencies to be completed by other lanes
|
|
493
|
+
*/
|
|
494
|
+
export async function waitForTaskDependencies(deps: string[], runDir: string): Promise<void> {
|
|
495
|
+
if (!deps || deps.length === 0) return;
|
|
496
|
+
|
|
497
|
+
const lanesRoot = path.dirname(runDir);
|
|
498
|
+
const pendingDeps = new Set(deps);
|
|
499
|
+
|
|
500
|
+
logger.info(`Waiting for task dependencies: ${deps.join(', ')}`);
|
|
501
|
+
|
|
502
|
+
while (pendingDeps.size > 0) {
|
|
503
|
+
for (const dep of pendingDeps) {
|
|
504
|
+
const [laneName, taskName] = dep.split(':');
|
|
505
|
+
if (!laneName || !taskName) {
|
|
506
|
+
logger.warn(`Invalid dependency format: ${dep}. Expected "lane:task"`);
|
|
507
|
+
pendingDeps.delete(dep);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
|
|
512
|
+
if (fs.existsSync(depStatePath)) {
|
|
513
|
+
try {
|
|
514
|
+
const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
|
|
515
|
+
if (state.completedTasks && state.completedTasks.includes(taskName)) {
|
|
516
|
+
logger.info(`✓ Dependency met: ${dep}`);
|
|
517
|
+
pendingDeps.delete(dep);
|
|
518
|
+
} else if (state.status === 'failed') {
|
|
519
|
+
throw new Error(`Dependency failed: ${dep} (Lane ${laneName} failed)`);
|
|
520
|
+
}
|
|
521
|
+
} catch (e: any) {
|
|
522
|
+
if (e.message.includes('Dependency failed')) throw e;
|
|
523
|
+
// Ignore parse errors, file might be being written
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (pendingDeps.size > 0) {
|
|
529
|
+
await new Promise(resolve => setTimeout(resolve, 5000)); // Poll every 5 seconds
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Merge branches from dependency lanes
|
|
536
|
+
*/
|
|
537
|
+
export async function mergeDependencyBranches(deps: string[], runDir: string, worktreeDir: string): Promise<void> {
|
|
538
|
+
if (!deps || deps.length === 0) return;
|
|
539
|
+
|
|
540
|
+
const lanesRoot = path.dirname(runDir);
|
|
541
|
+
const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
|
|
542
|
+
|
|
543
|
+
for (const laneName of lanesToMerge) {
|
|
544
|
+
const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
|
|
545
|
+
if (!fs.existsSync(depStatePath)) continue;
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
|
|
549
|
+
if (state.pipelineBranch) {
|
|
550
|
+
logger.info(`Merging branch from ${laneName}: ${state.pipelineBranch}`);
|
|
551
|
+
|
|
552
|
+
// Ensure we have the latest
|
|
553
|
+
git.runGit(['fetch', 'origin', state.pipelineBranch], { cwd: worktreeDir, silent: true });
|
|
554
|
+
|
|
555
|
+
git.merge(state.pipelineBranch, {
|
|
556
|
+
cwd: worktreeDir,
|
|
557
|
+
noFf: true,
|
|
558
|
+
message: `chore: merge task dependency from ${laneName}`
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
} catch (e) {
|
|
562
|
+
logger.error(`Failed to merge branch from ${laneName}: ${e}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
487
567
|
/**
|
|
488
568
|
* Run a single task
|
|
489
569
|
*/
|
|
@@ -509,7 +589,8 @@ export async function runTask({
|
|
|
509
589
|
noGit?: boolean;
|
|
510
590
|
}): Promise<TaskExecutionResult> {
|
|
511
591
|
const model = task.model || config.model || 'sonnet-4.5';
|
|
512
|
-
const
|
|
592
|
+
const timeout = task.timeout || config.timeout;
|
|
593
|
+
const convoPath = safeJoin(runDir, 'conversation.jsonl');
|
|
513
594
|
|
|
514
595
|
logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
|
|
515
596
|
logger.info(`Model: ${model}`);
|
|
@@ -555,7 +636,7 @@ export async function runTask({
|
|
|
555
636
|
prompt: prompt1,
|
|
556
637
|
model,
|
|
557
638
|
signalDir: runDir,
|
|
558
|
-
timeout
|
|
639
|
+
timeout,
|
|
559
640
|
enableIntervention: config.enableIntervention,
|
|
560
641
|
outputFormat: config.agentOutputFormat,
|
|
561
642
|
});
|
|
@@ -603,6 +684,37 @@ export async function runTask({
|
|
|
603
684
|
if (!noGit) {
|
|
604
685
|
git.push(taskBranch, { cwd: worktreeDir, setUpstream: true });
|
|
605
686
|
}
|
|
687
|
+
|
|
688
|
+
// Automatic Review
|
|
689
|
+
const reviewEnabled = config.reviewAllTasks || task.acceptanceCriteria?.length || config.enableReview;
|
|
690
|
+
|
|
691
|
+
if (reviewEnabled) {
|
|
692
|
+
logger.section(`🔍 Reviewing Task: ${task.name}`);
|
|
693
|
+
const reviewResult = await runReviewLoop({
|
|
694
|
+
taskResult: {
|
|
695
|
+
taskName: task.name,
|
|
696
|
+
taskBranch: taskBranch,
|
|
697
|
+
acceptanceCriteria: task.acceptanceCriteria,
|
|
698
|
+
},
|
|
699
|
+
worktreeDir,
|
|
700
|
+
runDir,
|
|
701
|
+
config,
|
|
702
|
+
workChatId: chatId,
|
|
703
|
+
model, // Use the same model as requested
|
|
704
|
+
cursorAgentSend,
|
|
705
|
+
cursorAgentCreateChat,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
if (!reviewResult.approved) {
|
|
709
|
+
logger.error(`❌ Task review failed after ${reviewResult.iterations} iterations`);
|
|
710
|
+
return {
|
|
711
|
+
taskName: task.name,
|
|
712
|
+
taskBranch,
|
|
713
|
+
status: 'ERROR',
|
|
714
|
+
error: reviewResult.error || 'Task failed to pass review criteria',
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
606
718
|
|
|
607
719
|
events.emit('task.completed', {
|
|
608
720
|
taskName: task.name,
|
|
@@ -670,18 +782,25 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
670
782
|
const repoRoot = noGit ? process.cwd() : git.getRepoRoot();
|
|
671
783
|
|
|
672
784
|
// Load existing state if resuming
|
|
673
|
-
const statePath =
|
|
785
|
+
const statePath = safeJoin(runDir, 'state.json');
|
|
674
786
|
let state: LaneState | null = null;
|
|
675
787
|
|
|
676
|
-
if (
|
|
677
|
-
|
|
788
|
+
if (fs.existsSync(statePath)) {
|
|
789
|
+
try {
|
|
790
|
+
state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
791
|
+
} catch (e) {
|
|
792
|
+
logger.warn(`Failed to load existing state from ${statePath}: ${e}`);
|
|
793
|
+
}
|
|
678
794
|
}
|
|
679
795
|
|
|
680
|
-
const
|
|
796
|
+
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
|
797
|
+
const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
|
|
798
|
+
|
|
681
799
|
// In noGit mode, use a simple local directory instead of worktree
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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, '-')));
|
|
685
804
|
|
|
686
805
|
if (startIndex === 0) {
|
|
687
806
|
logger.section('🚀 Starting Pipeline');
|
|
@@ -693,17 +812,54 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
693
812
|
logger.info(`Worktree: ${worktreeDir}`);
|
|
694
813
|
logger.info(`Tasks: ${config.tasks.length}`);
|
|
695
814
|
|
|
696
|
-
// Create worktree only if starting fresh
|
|
697
|
-
if (
|
|
815
|
+
// Create worktree only if starting fresh and worktree doesn't exist
|
|
816
|
+
if (!fs.existsSync(worktreeDir)) {
|
|
698
817
|
if (noGit) {
|
|
699
818
|
// In noGit mode, just create the directory
|
|
700
819
|
logger.info(`Creating work directory: ${worktreeDir}`);
|
|
701
820
|
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
702
821
|
} else {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} else if (!noGit) {
|
|
855
|
+
// If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
|
|
856
|
+
logger.info(`Reusing existing worktree: ${worktreeDir}`);
|
|
857
|
+
try {
|
|
858
|
+
git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
|
|
859
|
+
} catch (e) {
|
|
860
|
+
// If checkout fails, maybe the worktree is in a weird state.
|
|
861
|
+
// For now, just log it. In a more robust impl, we might want to repair it.
|
|
862
|
+
logger.warn(`Failed to checkout branch ${pipelineBranch} in existing worktree: ${e}`);
|
|
707
863
|
}
|
|
708
864
|
}
|
|
709
865
|
|
|
@@ -726,12 +882,17 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
726
882
|
dependencyRequest: null,
|
|
727
883
|
tasksFile, // Store tasks file for resume
|
|
728
884
|
dependsOn: config.dependsOn || [],
|
|
885
|
+
completedTasks: [],
|
|
729
886
|
};
|
|
730
887
|
} else {
|
|
731
888
|
state.status = 'running';
|
|
732
889
|
state.error = null;
|
|
733
890
|
state.dependencyRequest = null;
|
|
891
|
+
state.pipelineBranch = pipelineBranch;
|
|
892
|
+
state.worktreeDir = worktreeDir;
|
|
893
|
+
state.label = state.label || pipelineBranch;
|
|
734
894
|
state.dependsOn = config.dependsOn || [];
|
|
895
|
+
state.completedTasks = state.completedTasks || [];
|
|
735
896
|
}
|
|
736
897
|
|
|
737
898
|
saveState(statePath, state);
|
|
@@ -744,8 +905,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
744
905
|
const lanesRoot = path.dirname(runDir);
|
|
745
906
|
|
|
746
907
|
for (const depName of config.dependsOn) {
|
|
747
|
-
const depRunDir = path.join(lanesRoot, depName);
|
|
748
|
-
const depStatePath = path.join(depRunDir, 'state.json');
|
|
908
|
+
const depRunDir = path.join(lanesRoot, depName); // nosemgrep
|
|
909
|
+
const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
|
|
749
910
|
|
|
750
911
|
if (!fs.existsSync(depStatePath)) {
|
|
751
912
|
logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
|
|
@@ -791,8 +952,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
791
952
|
const lanesRoot = path.dirname(runDir);
|
|
792
953
|
|
|
793
954
|
for (const depName of config.dependsOn) {
|
|
794
|
-
const depRunDir =
|
|
795
|
-
const depStatePath =
|
|
955
|
+
const depRunDir = safeJoin(lanesRoot, depName);
|
|
956
|
+
const depStatePath = safeJoin(depRunDir, 'state.json');
|
|
796
957
|
|
|
797
958
|
if (!fs.existsSync(depStatePath)) {
|
|
798
959
|
continue;
|
|
@@ -811,8 +972,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
811
972
|
for (const entry of entries) {
|
|
812
973
|
if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
|
|
813
974
|
|
|
814
|
-
const srcPath =
|
|
815
|
-
const destPath =
|
|
975
|
+
const srcPath = safeJoin(src, entry.name);
|
|
976
|
+
const destPath = safeJoin(dest, entry.name);
|
|
816
977
|
|
|
817
978
|
if (entry.isDirectory()) {
|
|
818
979
|
copyFiles(srcPath, destPath);
|
|
@@ -836,6 +997,32 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
836
997
|
for (let i = startIndex; i < config.tasks.length; i++) {
|
|
837
998
|
const task = config.tasks[i]!;
|
|
838
999
|
const taskBranch = `${pipelineBranch}--${String(i + 1).padStart(2, '0')}-${task.name}`;
|
|
1000
|
+
|
|
1001
|
+
// Handle task-level dependencies
|
|
1002
|
+
if (task.dependsOn && task.dependsOn.length > 0) {
|
|
1003
|
+
state.status = 'waiting';
|
|
1004
|
+
state.waitingFor = task.dependsOn;
|
|
1005
|
+
saveState(statePath, state);
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
await waitForTaskDependencies(task.dependsOn, runDir);
|
|
1009
|
+
|
|
1010
|
+
if (!noGit) {
|
|
1011
|
+
await mergeDependencyBranches(task.dependsOn, runDir, worktreeDir);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
state.status = 'running';
|
|
1015
|
+
state.waitingFor = [];
|
|
1016
|
+
saveState(statePath, state);
|
|
1017
|
+
} catch (e: any) {
|
|
1018
|
+
state.status = 'failed';
|
|
1019
|
+
state.waitingFor = [];
|
|
1020
|
+
state.error = e.message;
|
|
1021
|
+
saveState(statePath, state);
|
|
1022
|
+
logger.error(`Task dependency wait/merge failed: ${e.message}`);
|
|
1023
|
+
process.exit(1);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
839
1026
|
|
|
840
1027
|
const result = await runTask({
|
|
841
1028
|
task,
|
|
@@ -853,6 +1040,10 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
853
1040
|
|
|
854
1041
|
// Update state
|
|
855
1042
|
state.currentTaskIndex = i + 1;
|
|
1043
|
+
state.completedTasks = state.completedTasks || [];
|
|
1044
|
+
if (!state.completedTasks.includes(task.name)) {
|
|
1045
|
+
state.completedTasks.push(task.name);
|
|
1046
|
+
}
|
|
856
1047
|
saveState(statePath, state);
|
|
857
1048
|
|
|
858
1049
|
// Handle blocked or error
|
|
@@ -914,7 +1105,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
|
|
|
914
1105
|
|
|
915
1106
|
if (entry.isDirectory()) {
|
|
916
1107
|
stats.dirs++;
|
|
917
|
-
const sub = getFileSummary(
|
|
1108
|
+
const sub = getFileSummary(safeJoin(dir, entry.name));
|
|
918
1109
|
stats.files += sub.files;
|
|
919
1110
|
stats.dirs += sub.dirs;
|
|
920
1111
|
} else {
|
|
@@ -956,11 +1147,13 @@ if (require.main === module) {
|
|
|
956
1147
|
const runDirIdx = args.indexOf('--run-dir');
|
|
957
1148
|
const startIdxIdx = args.indexOf('--start-index');
|
|
958
1149
|
const pipelineBranchIdx = args.indexOf('--pipeline-branch');
|
|
1150
|
+
const worktreeDirIdx = args.indexOf('--worktree-dir');
|
|
959
1151
|
const noGit = args.includes('--no-git');
|
|
960
1152
|
|
|
961
1153
|
const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
|
|
962
1154
|
const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
|
|
963
1155
|
const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
|
|
1156
|
+
const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
|
|
964
1157
|
|
|
965
1158
|
// Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
|
|
966
1159
|
const parts = runDir.split(path.sep);
|
|
@@ -992,6 +1185,9 @@ if (require.main === module) {
|
|
|
992
1185
|
if (forcedPipelineBranch) {
|
|
993
1186
|
config.pipelineBranch = forcedPipelineBranch;
|
|
994
1187
|
}
|
|
1188
|
+
if (forcedWorktreeDir) {
|
|
1189
|
+
config.worktreeDir = forcedWorktreeDir;
|
|
1190
|
+
}
|
|
995
1191
|
} catch (error: any) {
|
|
996
1192
|
console.error(`Failed to load tasks file: ${error.message}`);
|
|
997
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 = {
|
|
@@ -59,6 +60,7 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
|
|
|
59
60
|
// Review
|
|
60
61
|
enableReview: false,
|
|
61
62
|
reviewModel: 'sonnet-4.5-thinking',
|
|
63
|
+
reviewAllTasks: false,
|
|
62
64
|
maxReviewIterations: 3,
|
|
63
65
|
|
|
64
66
|
// Lane defaults
|
|
@@ -113,14 +115,14 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
|
|
|
113
115
|
* Get absolute path for tasks directory
|
|
114
116
|
*/
|
|
115
117
|
export function getTasksDir(config: CursorFlowConfig): string {
|
|
116
|
-
return
|
|
118
|
+
return safeJoin(config.projectRoot, config.tasksDir);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/**
|
|
120
122
|
* Get absolute path for logs directory
|
|
121
123
|
*/
|
|
122
124
|
export function getLogsDir(config: CursorFlowConfig): string {
|
|
123
|
-
return
|
|
125
|
+
return safeJoin(config.projectRoot, config.logsDir);
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
/**
|
|
@@ -156,11 +158,7 @@ export function validateConfig(config: CursorFlowConfig): boolean {
|
|
|
156
158
|
* Create default config file
|
|
157
159
|
*/
|
|
158
160
|
export function createDefaultConfig(projectRoot: string, force = false): string {
|
|
159
|
-
const configPath =
|
|
160
|
-
|
|
161
|
-
if (fs.existsSync(configPath) && !force) {
|
|
162
|
-
throw new Error(`Config file already exists: ${configPath}`);
|
|
163
|
-
}
|
|
161
|
+
const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
|
|
164
162
|
|
|
165
163
|
const template = `module.exports = {
|
|
166
164
|
// Directory configuration
|
|
@@ -182,6 +180,7 @@ export function createDefaultConfig(projectRoot: string, force = false): string
|
|
|
182
180
|
// Review configuration
|
|
183
181
|
enableReview: false,
|
|
184
182
|
reviewModel: 'sonnet-4.5-thinking',
|
|
183
|
+
reviewAllTasks: false,
|
|
185
184
|
maxReviewIterations: 3,
|
|
186
185
|
|
|
187
186
|
// Lane configuration
|
|
@@ -222,6 +221,15 @@ export function createDefaultConfig(projectRoot: string, force = false): string
|
|
|
222
221
|
};
|
|
223
222
|
`;
|
|
224
223
|
|
|
225
|
-
|
|
224
|
+
// Use atomic write with wx flag to avoid TOCTOU race condition (unless force is set)
|
|
225
|
+
try {
|
|
226
|
+
const writeFlag = force ? 'w' : 'wx';
|
|
227
|
+
fs.writeFileSync(configPath, template, { encoding: 'utf8', flag: writeFlag });
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
if (err.code === 'EEXIST') {
|
|
230
|
+
throw new Error(`Config file already exists: ${configPath}`);
|
|
231
|
+
}
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
226
234
|
return configPath;
|
|
227
235
|
}
|
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.
|
|
@@ -486,16 +487,46 @@ function validateBranchNames(
|
|
|
486
487
|
const remoteBranches = getAllRemoteBranches(repoRoot);
|
|
487
488
|
const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
|
|
488
489
|
|
|
489
|
-
// Collect branch prefixes from lanes
|
|
490
|
+
// Collect branch prefixes and pipeline branches from lanes
|
|
490
491
|
const branchPrefixes: { laneName: string; prefix: string }[] = [];
|
|
492
|
+
const pipelineBranches: { laneName: string; branch: string }[] = [];
|
|
491
493
|
|
|
492
494
|
for (const lane of lanes) {
|
|
493
495
|
const branchPrefix = lane.json?.branchPrefix;
|
|
494
496
|
if (branchPrefix) {
|
|
495
497
|
branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
|
|
496
498
|
}
|
|
499
|
+
|
|
500
|
+
const pipelineBranch = lane.json?.pipelineBranch;
|
|
501
|
+
if (pipelineBranch) {
|
|
502
|
+
pipelineBranches.push({ laneName: lane.fileName, branch: pipelineBranch });
|
|
503
|
+
}
|
|
497
504
|
}
|
|
498
505
|
|
|
506
|
+
// Check for pipeline branch collisions
|
|
507
|
+
const pipeMap = new Map<string, string[]>();
|
|
508
|
+
for (const { laneName, branch } of pipelineBranches) {
|
|
509
|
+
const existing = pipeMap.get(branch) || [];
|
|
510
|
+
existing.push(laneName);
|
|
511
|
+
pipeMap.set(branch, existing);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const [branch, laneNames] of pipeMap) {
|
|
515
|
+
if (laneNames.length > 1) {
|
|
516
|
+
addIssue(issues, {
|
|
517
|
+
id: 'branch.pipeline_collision',
|
|
518
|
+
severity: 'error',
|
|
519
|
+
title: 'Pipeline branch collision',
|
|
520
|
+
message: `Multiple lanes use the same pipelineBranch "${branch}": ${laneNames.join(', ')}`,
|
|
521
|
+
details: 'Each lane should have a unique pipelineBranch to avoid worktree conflicts during parallel execution.',
|
|
522
|
+
fixes: [
|
|
523
|
+
'Update the pipelineBranch in each lane JSON file to be unique',
|
|
524
|
+
'Or remove pipelineBranch to let CursorFlow generate unique ones',
|
|
525
|
+
],
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
499
530
|
// Check for branch prefix collisions between lanes
|
|
500
531
|
const prefixMap = new Map<string, string[]>();
|
|
501
532
|
for (const { laneName, prefix } of branchPrefixes) {
|
|
@@ -582,7 +613,7 @@ function validateBranchNames(
|
|
|
582
613
|
const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
|
|
583
614
|
|
|
584
615
|
export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
|
|
585
|
-
const statusPath =
|
|
616
|
+
const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
|
|
586
617
|
const statusDir = path.dirname(statusPath);
|
|
587
618
|
|
|
588
619
|
if (!fs.existsSync(statusDir)) {
|
|
@@ -600,7 +631,7 @@ export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
|
|
|
600
631
|
}
|
|
601
632
|
|
|
602
633
|
export function getDoctorStatus(repoRoot: string): { lastRun: number; ok: boolean; issueCount: number } | null {
|
|
603
|
-
const statusPath =
|
|
634
|
+
const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
|
|
604
635
|
if (!fs.existsSync(statusPath)) return null;
|
|
605
636
|
|
|
606
637
|
try {
|
|
@@ -800,7 +831,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
800
831
|
});
|
|
801
832
|
} else {
|
|
802
833
|
// Advanced check: .gitignore check for worktrees
|
|
803
|
-
const gitignorePath =
|
|
834
|
+
const gitignorePath = safeJoin(gitCwd, '.gitignore');
|
|
804
835
|
const worktreeDirName = '_cursorflow'; // Default directory name
|
|
805
836
|
if (fs.existsSync(gitignorePath)) {
|
|
806
837
|
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
@@ -823,7 +854,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
823
854
|
if (options.tasksDir) {
|
|
824
855
|
const tasksDirAbs = path.isAbsolute(options.tasksDir)
|
|
825
856
|
? options.tasksDir
|
|
826
|
-
:
|
|
857
|
+
: safeJoin(cwd, options.tasksDir);
|
|
827
858
|
context.tasksDir = tasksDirAbs;
|
|
828
859
|
|
|
829
860
|
if (!fs.existsSync(tasksDirAbs)) {
|