diff --git a/.gitignore b/.gitignore index 1994a53..cd81520 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ Homestead.yaml Thumbs.db /docs /CLAUDE.md + +# SQLite test databases +planforge_test_* +planforge diff --git a/app/Actions/GenerateTasksFromTechSpec.php b/app/Actions/GenerateTasksFromTechSpec.php new file mode 100644 index 0000000..53d1a7a --- /dev/null +++ b/app/Actions/GenerateTasksFromTechSpec.php @@ -0,0 +1,71 @@ +id) + ->where('type', DocumentType::Tech) + ->with('currentVersion') + ->first(); + + if (! $techDoc?->currentVersion) { + throw new RuntimeException('No Tech Spec found. Generate a Tech Spec first.'); + } + + $prdDoc = Document::where('project_id', $project->id) + ->where('type', DocumentType::Prd) + ->with('currentVersion') + ->first(); + + $run = PlanRun::create([ + 'project_id' => $project->id, + 'triggered_by' => $userId, + 'status' => PlanRunStatus::Queued, + 'provider' => $project->preferred_provider ?? 'anthropic', + 'model' => $project->preferred_model ?? 'claude-sonnet-4-20250514', + 'input_snapshot' => [ + 'tech_version_id' => $techDoc->currentVersion->id, + 'prd_version_id' => $prdDoc?->currentVersion?->id, + ], + ]); + + $step = PlanRunStep::create([ + 'plan_run_id' => $run->id, + 'step' => StepType::Tasks, + 'status' => PlanRunStepStatus::Queued, + 'attempt' => 0, + 'provider' => $run->provider, + 'model' => $run->model, + ]); + + $taskSet = TaskSet::create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techDoc->currentVersion->id, + 'source_prd_version_id' => $prdDoc?->currentVersion?->id, + 'plan_run_id' => $run->id, + 'plan_run_step_id' => $step->id, + ]); + + GenerateTasksJob::dispatch($run->id, $taskSet->id); + + return $taskSet->load('planRunStep'); + }); + } +} diff --git a/app/Enums/TaskCategory.php b/app/Enums/TaskCategory.php new file mode 100644 index 0000000..496f25b --- /dev/null +++ b/app/Enums/TaskCategory.php @@ -0,0 +1,13 @@ +afterCommit(); + } + + public function uniqueId(): string + { + return $this->planRunId.':tasks'; + } + + public function middleware(): array + { + return [new RateLimited('llm:requests')]; + } + + public function handle(): void + { + $run = PlanRun::with('project')->findOrFail($this->planRunId); + $taskSet = TaskSet::findOrFail($this->taskSetId); + $step = PlanRunStep::where('plan_run_id', $run->id) + ->where('step', StepType::Tasks) + ->firstOrFail(); + + $step->update([ + 'status' => PlanRunStepStatus::Running, + 'attempt' => $step->attempt + 1, + 'started_at' => now(), + 'next_attempt_at' => null, + ]); + + try { + $techSpec = $taskSet->sourceTechVersion->content_md; + + $prdDoc = Document::where('project_id', $run->project_id) + ->where('type', DocumentType::Prd) + ->with('currentVersion') + ->first(); + + $prdSummary = $prdDoc?->currentVersion?->summary + ?? $this->truncate($prdDoc?->currentVersion?->content_md, 2000); + + $response = $this->callAI($run, $techSpec, $prdSummary); + + $this->storeRateLimits($step, $response); + $this->persistTasks($run->project_id, $taskSet, $response->structured); + + $step->update([ + 'status' => PlanRunStepStatus::Succeeded, + 'finished_at' => now(), + ]); + + $taskSet->update([ + 'meta' => ['task_count' => count($response->structured['tasks'] ?? [])], + ]); + + $run->update([ + 'status' => PlanRunStatus::Succeeded, + 'finished_at' => now(), + ]); + + } catch (PrismRateLimitedException $e) { + $this->handleRateLimit($e, $step); + } catch (Throwable $e) { + $this->handleError($e, $step, $run); + } + } + + private function callAI(PlanRun $run, string $techSpec, ?string $prdSummary) + { + $provider = $this->resolveProvider($run->provider); + + $builder = Prism::structured() + ->using($provider, $run->model) + ->withSchema(TasksSchema::make()) + ->withMaxTokens(8000) + ->withSystemPrompt(view('prompts.tasks.system')->render()) + ->withPrompt(view('prompts.tasks.user', [ + 'project' => $run->project, + 'techSpec' => $techSpec, + 'prdSummary' => $prdSummary, + ])->render()) + ->withClientOptions(['timeout' => 180]); + + if ($provider === Provider::OpenAI) { + $builder = $builder->withProviderOptions(['schema' => ['strict' => true]]); + } + + return $builder->asStructured(); + } + + private function persistTasks(string $projectId, TaskSet $taskSet, array $structured): void + { + DB::transaction(function () use ($projectId, $taskSet, $structured) { + // Soft delete previous AI-generated tasks + Task::where('project_id', $projectId) + ->whereNotNull('task_set_id') + ->delete(); + + $tasks = $structured['tasks'] ?? []; + $tempIdMap = []; + + // Generate lexicographic positions for proper Flowforge ordering + $currentRank = Rank::forEmptySequence(); + + foreach ($tasks as $data) { + $task = Task::create([ + 'project_id' => $projectId, + 'task_set_id' => $taskSet->id, + 'plan_run_id' => $taskSet->plan_run_id, + 'plan_run_step_id' => $taskSet->plan_run_step_id, + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'category' => $data['category'] ?? null, + 'priority' => $data['priority'] ?? 'med', + 'status' => $data['status'] ?? 'todo', + 'estimate' => $data['estimate'] ?? null, + 'acceptance_criteria' => $data['acceptance_criteria'] ?? [], + 'source_refs' => $data['source_refs'] ?? [], + 'labels' => $data['labels'] ?? [], + 'depends_on' => [], + 'position' => $currentRank->get(), + ]); + + // Generate next position + $currentRank = Rank::after($currentRank); + + if (isset($data['temp_id'])) { + $tempIdMap[$data['temp_id']] = $task->id; + } + } + + // Resolve dependencies + foreach ($tasks as $data) { + if (empty($data['depends_on']) || ! isset($data['temp_id'])) { + continue; + } + + $taskId = $tempIdMap[$data['temp_id']] ?? null; + if (! $taskId) { + continue; + } + + $resolvedDeps = collect($data['depends_on']) + ->map(fn ($tempId) => $tempIdMap[$tempId] ?? null) + ->filter() + ->values() + ->toArray(); + + Task::where('id', $taskId)->update(['depends_on' => $resolvedDeps]); + } + }); + } + + private function storeRateLimits(PlanRunStep $step, $response): void + { + if (! $response->meta->rateLimits) { + return; + } + + $step->update([ + 'rate_limits' => collect($response->meta->rateLimits)->map(fn ($rl) => [ + 'name' => $rl->name, + 'limit' => $rl->limit, + 'remaining' => $rl->remaining, + 'resetsAt' => $rl->resetsAt?->toIso8601String(), + ])->toArray(), + ]); + } + + private function handleRateLimit(PrismRateLimitedException $e, PlanRunStep $step): void + { + $resetAt = collect($e->rateLimits) + ->map(fn ($rl) => $rl->resetsAt) + ->filter() + ->sort() + ->first(); + + $delaySeconds = $resetAt + ? max(5, now()->diffInSeconds($resetAt, false) + 5) + : 60; + + $step->update([ + 'status' => PlanRunStepStatus::Delayed, + 'next_attempt_at' => now()->addSeconds($delaySeconds), + 'rate_limits' => collect($e->rateLimits)->map(fn ($rl) => [ + 'name' => $rl->name, + 'limit' => $rl->limit, + 'remaining' => $rl->remaining, + 'resetsAt' => $rl->resetsAt?->toIso8601String(), + ])->toArray(), + ]); + + $this->release($delaySeconds); + } + + private function handleError(Throwable $e, PlanRunStep $step, PlanRun $run): void + { + $isTransient = str_contains(strtolower($e->getMessage()), 'overload') + || str_contains(strtolower($e->getMessage()), 'capacity') + || str_contains(strtolower($e->getMessage()), 'temporarily'); + + if ($isTransient && $this->attempts() < $this->tries) { + $delaySeconds = $this->backoff[$this->attempts() - 1] ?? 300; + + $step->update([ + 'status' => PlanRunStepStatus::Delayed, + 'next_attempt_at' => now()->addSeconds($delaySeconds), + 'error_message' => $e->getMessage(), + ]); + + $this->release($delaySeconds); + + return; + } + + $step->update([ + 'status' => PlanRunStepStatus::Failed, + 'error_message' => $e->getMessage(), + 'finished_at' => now(), + ]); + + $run->update([ + 'status' => PlanRunStatus::Failed, + 'error_message' => $e->getMessage(), + 'finished_at' => now(), + ]); + + throw $e; + } + + private function resolveProvider(string $provider): Provider + { + return match ($provider) { + 'anthropic' => Provider::Anthropic, + 'openai' => Provider::OpenAI, + 'gemini' => Provider::Gemini, + 'mistral' => Provider::Mistral, + 'groq' => Provider::Groq, + default => Provider::Anthropic, + }; + } + + private function truncate(?string $text, int $length): ?string + { + if ($text === null) { + return null; + } + + return strlen($text) > $length ? substr($text, 0, $length).'...' : $text; + } +} diff --git a/app/Livewire/Projects/Tabs/KanbanBoard.php b/app/Livewire/Projects/Tabs/KanbanBoard.php index ee72741..168dcea 100644 --- a/app/Livewire/Projects/Tabs/KanbanBoard.php +++ b/app/Livewire/Projects/Tabs/KanbanBoard.php @@ -2,7 +2,11 @@ namespace App\Livewire\Projects\Tabs; +use App\Actions\GenerateTasksFromTechSpec; +use App\Enums\PlanRunStepStatus; +use App\Models\Project; use App\Models\Task; +use App\Models\TaskSet; use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Contracts\HasActions; use Filament\Actions\CreateAction; @@ -15,6 +19,7 @@ use Filament\Forms\Contracts\HasForms; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Schema; +use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; use Relaticle\Flowforge\Board; @@ -50,18 +55,43 @@ public function board(Board $board): Board Column::make('doing')->label('In Progress')->color('info'), Column::make('done')->label('Done')->color('success'), ]) - ->cardSchema(fn (Schema $schema) => $schema->components([ - TextEntry::make('title') - ->weight('bold') - ->size('sm'), - TextEntry::make('description') - ->limit(60) - ->color('gray'), - TextEntry::make('estimate') - ->badge() - ->color('primary') - ->visible(fn ($record) => filled($record->estimate)), - ])) + ->cardSchema(fn (Schema $schema) => $schema + ->extraAttributes(['class' => 'space-y-2']) + ->components([ + TextEntry::make('description') + ->hiddenLabel() + ->limit(100) + ->color('gray') + ->size('sm'), + TextEntry::make('badges') + ->hiddenLabel() + ->state(fn ($record) => collect([ + $record->category ? [ + 'label' => match ($record->category?->value ?? $record->category) { + 'backend' => 'Backend', + 'frontend' => 'Frontend', + 'db' => 'DB', + 'infra' => 'Infra', + 'tests' => 'Tests', + 'docs' => 'Docs', + default => $record->category, + }, + 'color' => match ($record->category?->value ?? $record->category) { + 'backend' => 'success', + 'frontend' => 'info', + 'db' => 'warning', + 'tests' => 'primary', + default => 'gray', + }, + ] : null, + $record->estimate ? [ + 'label' => $record->estimate, + 'color' => 'gray', + 'icon' => true, + ] : null, + ])->filter()->values()->toArray()) + ->view('components.task-badges'), + ])) ->columnActions([ CreateAction::make() ->label('Add task') @@ -121,9 +151,58 @@ public function board(Board $board): Board #[On('tasksUpdated')] #[On('planRunCompleted')] + #[On('taskGenerationStarted')] public function refreshBoard(): void { - // Flowforge handles refresh automatically via Livewire reactivity + unset($this->latestTaskSet); + unset($this->isStale); + unset($this->isGeneratingTasks); + } + + #[Computed] + public function latestTaskSet(): ?TaskSet + { + return TaskSet::where('project_id', $this->projectId) + ->with(['planRunStep', 'sourceTechVersion']) + ->latest() + ->first(); + } + + #[Computed] + public function isStale(): bool + { + return $this->latestTaskSet?->isStale() ?? false; + } + + #[Computed] + public function isGeneratingTasks(): bool + { + $taskSet = $this->latestTaskSet; + + if (! $taskSet) { + return false; + } + + return in_array($taskSet->status, [ + PlanRunStepStatus::Queued, + PlanRunStepStatus::Running, + PlanRunStepStatus::Delayed, + ]); + } + + public function regenerateTasks(): void + { + if ($this->isGeneratingTasks) { + return; + } + + $project = Project::findOrFail($this->projectId); + $action = new GenerateTasksFromTechSpec; + $action->handle($project); + + unset($this->latestTaskSet); + unset($this->isStale); + unset($this->isGeneratingTasks); } public function render() diff --git a/app/Livewire/Projects/Tabs/Tech.php b/app/Livewire/Projects/Tabs/Tech.php index 22993d4..0596f61 100644 --- a/app/Livewire/Projects/Tabs/Tech.php +++ b/app/Livewire/Projects/Tabs/Tech.php @@ -2,9 +2,13 @@ namespace App\Livewire\Projects\Tabs; +use App\Actions\GenerateTasksFromTechSpec; use App\Enums\DocumentType; +use App\Enums\PlanRunStepStatus; use App\Models\Document; use App\Models\DocumentVersion; +use App\Models\Project; +use App\Models\TaskSet; use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; @@ -83,6 +87,54 @@ public function handlePlanRunCompleted(): void $this->loadContent(); } + #[Computed] + public function latestTaskSet(): ?TaskSet + { + return TaskSet::where('project_id', $this->projectId) + ->with('planRunStep') + ->latest() + ->first(); + } + + #[Computed] + public function isGeneratingTasks(): bool + { + $taskSet = $this->latestTaskSet; + + if (! $taskSet) { + return false; + } + + return in_array($taskSet->status, [ + PlanRunStepStatus::Queued, + PlanRunStepStatus::Running, + PlanRunStepStatus::Delayed, + ]); + } + + public function generateTasks(): void + { + if ($this->isGeneratingTasks) { + return; + } + + $project = Project::findOrFail($this->projectId); + $action = new GenerateTasksFromTechSpec; + $action->handle($project); + + unset($this->latestTaskSet); + unset($this->isGeneratingTasks); + + $this->dispatch('taskGenerationStarted'); + } + + #[On('taskGenerationCompleted')] + public function handleTaskGenerationCompleted(): void + { + unset($this->latestTaskSet); + unset($this->isGeneratingTasks); + } + public function render() { return view('livewire.projects.tabs.tech'); diff --git a/app/Models/Project.php b/app/Models/Project.php index 7e0e455..acf9cc7 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -56,4 +56,14 @@ public function tasks(): HasMany { return $this->hasMany(Task::class); } + + public function taskSets(): HasMany + { + return $this->hasMany(TaskSet::class); + } + + public function latestTaskSet(): ?TaskSet + { + return $this->taskSets()->latest()->first(); + } } diff --git a/app/Models/Task.php b/app/Models/Task.php index be9578e..37caf07 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Enums\TaskCategory; +use App\Enums\TaskPriority; use App\Enums\TaskStatus; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,13 +21,17 @@ class Task extends Model 'story_id', 'plan_run_id', 'plan_run_step_id', + 'task_set_id', 'title', 'description', 'acceptance_criteria', 'estimate', 'labels', 'depends_on', + 'source_refs', 'status', + 'category', + 'priority', 'position', ]; @@ -35,7 +41,10 @@ protected function casts(): array 'acceptance_criteria' => 'array', 'labels' => 'array', 'depends_on' => 'array', + 'source_refs' => 'array', 'status' => TaskStatus::class, + 'category' => TaskCategory::class, + 'priority' => TaskPriority::class, ]; } @@ -63,4 +72,9 @@ public function planRunStep(): BelongsTo { return $this->belongsTo(PlanRunStep::class); } + + public function taskSet(): BelongsTo + { + return $this->belongsTo(TaskSet::class); + } } diff --git a/app/Models/TaskSet.php b/app/Models/TaskSet.php new file mode 100644 index 0000000..e111266 --- /dev/null +++ b/app/Models/TaskSet.php @@ -0,0 +1,84 @@ + 'array', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function sourceTechVersion(): BelongsTo + { + return $this->belongsTo(DocumentVersion::class, 'source_tech_version_id'); + } + + public function sourcePrdVersion(): BelongsTo + { + return $this->belongsTo(DocumentVersion::class, 'source_prd_version_id'); + } + + public function planRun(): BelongsTo + { + return $this->belongsTo(PlanRun::class); + } + + public function planRunStep(): BelongsTo + { + return $this->belongsTo(PlanRunStep::class); + } + + public function tasks(): HasMany + { + return $this->hasMany(Task::class); + } + + /** + * Get status from the associated plan run step. + */ + public function getStatusAttribute(): ?PlanRunStepStatus + { + return $this->planRunStep?->status; + } + + /** + * Check if tasks are stale (tech spec has been updated since generation). + */ + public function isStale(): bool + { + $latestTechVersion = $this->project->documents() + ->where('type', DocumentType::Tech) + ->first() + ?->currentVersion; + + return $latestTechVersion + && $this->source_tech_version_id !== $latestTechVersion->id; + } +} diff --git a/app/Schemas/TasksSchema.php b/app/Schemas/TasksSchema.php new file mode 100644 index 0000000..5ca9f02 --- /dev/null +++ b/app/Schemas/TasksSchema.php @@ -0,0 +1,89 @@ + + */ +class TaskSetFactory extends Factory +{ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'source_tech_version_id' => DocumentVersion::factory(), + 'source_prd_version_id' => null, + 'plan_run_id' => null, + 'plan_run_step_id' => null, + 'meta' => null, + ]; + } + + public function withPrdVersion(): static + { + return $this->state(fn (array $attributes) => [ + 'source_prd_version_id' => DocumentVersion::factory(), + ]); + } + + public function withMeta(array $meta): static + { + return $this->state(fn (array $attributes) => [ + 'meta' => $meta, + ]); + } +} diff --git a/database/migrations/2025_12_31_052124_create_task_sets_table.php b/database/migrations/2025_12_31_052124_create_task_sets_table.php new file mode 100644 index 0000000..c0b7f2a --- /dev/null +++ b/database/migrations/2025_12_31_052124_create_task_sets_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->foreignUlid('project_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('source_tech_version_id')->constrained('document_versions')->cascadeOnDelete(); + $table->foreignUlid('source_prd_version_id')->nullable()->constrained('document_versions')->nullOnDelete(); + $table->foreignUlid('plan_run_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('plan_run_step_id')->nullable()->constrained()->nullOnDelete(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['project_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('task_sets'); + } +}; diff --git a/database/migrations/2025_12_31_052202_add_task_generation_fields_to_tasks_table.php b/database/migrations/2025_12_31_052202_add_task_generation_fields_to_tasks_table.php new file mode 100644 index 0000000..648518c --- /dev/null +++ b/database/migrations/2025_12_31_052202_add_task_generation_fields_to_tasks_table.php @@ -0,0 +1,33 @@ +foreignUlid('task_set_id')->nullable()->after('plan_run_step_id')->constrained()->cascadeOnDelete(); + $table->string('category')->nullable()->after('status'); + $table->string('priority')->default('med')->after('category'); + $table->json('source_refs')->nullable()->after('depends_on'); + + $table->index('task_set_id'); + $table->index('category'); + $table->index('priority'); + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropForeign(['task_set_id']); + $table->dropIndex(['task_set_id']); + $table->dropIndex(['category']); + $table->dropIndex(['priority']); + $table->dropColumn(['task_set_id', 'category', 'priority', 'source_refs']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ddfa8e5..99e900b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ use App\Models\Task; use App\Models\User; use Illuminate\Database\Seeder; +use Relaticle\Flowforge\Services\Rank; class DatabaseSeeder extends Seeder { @@ -113,6 +114,11 @@ public function run(): void 'sort_order' => 0, ]); + // Generate lexicographic positions for Flowforge + $rank1 = Rank::forEmptySequence(); + $rank2 = Rank::after($rank1); + $rank3 = Rank::after($rank2); + Task::create([ 'project_id' => $project->id, 'epic_id' => $epic->id, @@ -124,7 +130,7 @@ public function run(): void 'estimate' => '2h', 'labels' => ['backend', 'database'], 'status' => TaskStatus::Done, - 'board_order' => 0, + 'position' => $rank1->get(), ]); Task::create([ @@ -138,7 +144,7 @@ public function run(): void 'estimate' => '3h', 'labels' => ['frontend', 'livewire'], 'status' => TaskStatus::Doing, - 'board_order' => 1, + 'position' => $rank2->get(), ]); Task::create([ @@ -149,7 +155,7 @@ public function run(): void 'estimate' => '4h', 'labels' => ['frontend', 'ux'], 'status' => TaskStatus::Todo, - 'board_order' => 2, + 'position' => $rank3->get(), ]); } } diff --git a/resources/views/components/task-badges.blade.php b/resources/views/components/task-badges.blade.php new file mode 100644 index 0000000..a03182e --- /dev/null +++ b/resources/views/components/task-badges.blade.php @@ -0,0 +1,26 @@ +@php + $badges = $getState() ?? []; +@endphp + +@if(count($badges) > 0) +
Drag and drop tasks across stages
+Drag and drop tasks across stages
+ @if($this->latestTaskSet) + + Generated from Tech Spec v{{ $this->latestTaskSet->sourceTechVersion?->id ? substr($this->latestTaskSet->sourceTechVersion->id, 0, 8) : 'N/A' }} + + @endif ++ {{ __('flowforge::flowforge.no_cards_in_column', ['cardLabel' => $pluralCardLabel]) }} +
+