diff --git a/app/Http/Controllers/RoutineController.php b/app/Http/Controllers/RoutineController.php new file mode 100644 index 0000000..fb6eb2a --- /dev/null +++ b/app/Http/Controllers/RoutineController.php @@ -0,0 +1,127 @@ +user()->currentTeam; + + $routines = $team->routines() + ->with('agent:id,name,emoji') + ->orderByDesc('created_at') + ->get(); + + return Inertia::render('company/routines/index', [ + 'routines' => $routines, + 'agents' => $team->agents()->select(['id', 'name', 'emoji'])->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $team = $request->user()->currentTeam; + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:5000'], + 'agent_id' => ['required', 'string', 'exists:agents,id'], + 'cron_expression' => ['required', 'string', 'max:255'], + 'timezone' => ['nullable', 'string', 'timezone:all'], + ]); + + // Verify agent belongs to team + abort_unless( + $team->agents()->where('id', $validated['agent_id'])->exists(), + 422, + 'Agent does not belong to this team.', + ); + + // Enforce per-team limit + $activeCount = $team->routines()->where('status', 'active')->count(); + abort_if($activeCount >= 50, 422, 'Maximum of 50 active routines per team.'); + + $routine = $team->routines()->create([ + ...$validated, + 'timezone' => $validated['timezone'] ?? 'UTC', + 'status' => 'active', + ]); + + $routine->update([ + 'next_run_at' => $routine->computeNextRun(), + ]); + + return redirect()->route('company.routines.index') + ->with('success', 'Routine created.'); + } + + public function update(Request $request, Routine $routine): RedirectResponse + { + $this->authorizeTeam($request, $routine); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:5000'], + 'agent_id' => ['required', 'string', 'exists:agents,id'], + 'cron_expression' => ['required', 'string', 'max:255'], + 'timezone' => ['nullable', 'string', 'timezone:all'], + ]); + + $team = $request->user()->currentTeam; + + abort_unless( + $team->agents()->where('id', $validated['agent_id'])->exists(), + 422, + 'Agent does not belong to this team.', + ); + + $routine->update([ + ...$validated, + 'timezone' => $validated['timezone'] ?? 'UTC', + ]); + + $routine->update([ + 'next_run_at' => $routine->computeNextRun(), + ]); + + return redirect()->route('company.routines.index') + ->with('success', 'Routine updated.'); + } + + public function toggle(Request $request, Routine $routine): RedirectResponse + { + $this->authorizeTeam($request, $routine); + + $newStatus = $routine->status === 'active' ? 'paused' : 'active'; + + $routine->update([ + 'status' => $newStatus, + 'next_run_at' => $newStatus === 'active' ? $routine->computeNextRun() : null, + ]); + + return redirect()->route('company.routines.index') + ->with('success', 'Routine '.($newStatus === 'active' ? 'activated' : 'paused').'.'); + } + + public function destroy(Request $request, Routine $routine): RedirectResponse + { + $this->authorizeTeam($request, $routine); + + $routine->delete(); + + return redirect()->route('company.routines.index') + ->with('success', 'Routine deleted.'); + } + + private function authorizeTeam(Request $request, Routine $routine): void + { + abort_unless($routine->team_id === $request->user()->current_team_id, 403); + } +} diff --git a/app/Jobs/ProcessRoutinesJob.php b/app/Jobs/ProcessRoutinesJob.php new file mode 100644 index 0000000..d84ab37 --- /dev/null +++ b/app/Jobs/ProcessRoutinesJob.php @@ -0,0 +1,91 @@ +where('status', 'active') + ->where('next_run_at', '<=', now()) + ->with('team') + ->get(); + + foreach ($routines as $routine) { + $this->processRoutine($routine); + } + } + + private function processRoutine(Routine $routine): void + { + // Skip if a pending task from this routine already exists + $hasPendingTask = Task::query() + ->where('routine_id', $routine->id) + ->whereIn('status', ['todo', 'in_progress', 'blocked']) + ->exists(); + + if ($hasPendingTask) { + // Still update next_run_at so we check again later + $routine->update([ + 'next_run_at' => $routine->computeNextRun(), + ]); + + return; + } + + // Enforce per-team limit of 50 active routines + $activeCount = Routine::query() + ->where('team_id', $routine->team_id) + ->where('status', 'active') + ->count(); + + if ($activeCount > 50) { + Log::warning('Team has exceeded 50 active routines, skipping routine processing', [ + 'team_id' => $routine->team_id, + 'routine_id' => $routine->id, + ]); + + return; + } + + $identifier = $this->generateIdentifier($routine->team); + + Task::query()->create([ + 'team_id' => $routine->team_id, + 'agent_id' => $routine->agent_id, + 'routine_id' => $routine->id, + 'created_by_type' => 'routine', + 'created_by_id' => $routine->id, + 'title' => $routine->title, + 'description' => $routine->description, + 'status' => 'todo', + 'priority' => 'medium', + 'identifier' => $identifier, + ]); + + $routine->update([ + 'last_run_at' => now(), + 'next_run_at' => $routine->computeNextRun(), + ]); + } + + /** + * Generate a sequential task identifier like TSK-1, TSK-2, etc. + */ + private function generateIdentifier(Team $team): string + { + $count = $team->tasks()->count(); + + return 'TSK-'.($count + 1); + } +} diff --git a/app/Models/Routine.php b/app/Models/Routine.php new file mode 100644 index 0000000..07b5fca --- /dev/null +++ b/app/Models/Routine.php @@ -0,0 +1,91 @@ + */ + use HasFactory, HasUlids; + + /** + * @var list + */ + protected $fillable = [ + 'team_id', + 'agent_id', + 'title', + 'description', + 'cron_expression', + 'timezone', + 'status', + 'last_run_at', + 'next_run_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + /** + * @return BelongsTo + */ + public function agent(): BelongsTo + { + return $this->belongsTo(Agent::class); + } + + /** + * @return HasMany + */ + public function tasks(): HasMany + { + return $this->hasMany(Task::class); + } + + /** + * Compute the next run time based on the cron expression and timezone. + */ + public function computeNextRun(): ?Carbon + { + try { + $next = CronExpression::factory($this->cron_expression) + ->getNextRunDate(now(), 0, false, $this->timezone); + + return Carbon::instance($next); + } catch (\Throwable) { + return null; + } + } + + /** + * Determine if this routine is due to run. + */ + public function isDue(): bool + { + return $this->next_run_at && $this->next_run_at->lte(now()); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index 1525603..bfd7314 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -33,6 +33,7 @@ class Task extends Model 'completed_at', 'parent_task_id', 'goal_id', + 'routine_id', 'checked_out_by_run', 'checked_out_at', 'checkout_expires_at', @@ -109,6 +110,14 @@ public function goal(): BelongsTo return $this->belongsTo(Goal::class); } + /** + * @return BelongsTo + */ + public function routine(): BelongsTo + { + return $this->belongsTo(Routine::class); + } + /** * The workforce agent assigned to this task. * diff --git a/app/Models/Team.php b/app/Models/Team.php index 95e1f66..6128436 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -100,6 +100,14 @@ public function tasks(): HasMany return $this->hasMany(Task::class); } + /** + * @return HasMany + */ + public function routines(): HasMany + { + return $this->hasMany(Routine::class); + } + /** * @return HasMany */ diff --git a/database/factories/RoutineFactory.php b/database/factories/RoutineFactory.php new file mode 100644 index 0000000..fae5edd --- /dev/null +++ b/database/factories/RoutineFactory.php @@ -0,0 +1,39 @@ + + */ +class RoutineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'agent_id' => Agent::factory(), + 'title' => fake()->sentence(4), + 'description' => fake()->paragraph(), + 'cron_expression' => '0 9 * * *', + 'timezone' => 'UTC', + 'status' => 'active', + 'next_run_at' => now()->addDay(), + ]; + } + + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'paused', + 'next_run_at' => null, + ]); + } +} diff --git a/database/migrations/2026_04_08_220002_create_routines_table.php b/database/migrations/2026_04_08_220002_create_routines_table.php new file mode 100644 index 0000000..7f22852 --- /dev/null +++ b/database/migrations/2026_04_08_220002_create_routines_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->foreignUlid('team_id')->constrained('teams')->cascadeOnDelete(); + $table->foreignUlid('agent_id')->constrained('agents')->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('cron_expression'); + $table->string('timezone')->default('UTC'); + $table->string('status')->default('active'); + $table->timestamp('last_run_at')->nullable(); + $table->timestamp('next_run_at')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'status']); + $table->index(['team_id', 'agent_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('routines'); + } +}; diff --git a/database/migrations/2026_04_08_220003_add_routine_id_to_tasks_table.php b/database/migrations/2026_04_08_220003_add_routine_id_to_tasks_table.php new file mode 100644 index 0000000..ab52441 --- /dev/null +++ b/database/migrations/2026_04_08_220003_add_routine_id_to_tasks_table.php @@ -0,0 +1,22 @@ +foreignUlid('routine_id')->nullable()->after('goal_id')->constrained('routines')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropConstrainedForeignId('routine_id'); + }); + } +}; diff --git a/resources/js/pages/company/routines/index.tsx b/resources/js/pages/company/routines/index.tsx new file mode 100644 index 0000000..520acc6 --- /dev/null +++ b/resources/js/pages/company/routines/index.tsx @@ -0,0 +1,405 @@ +import { Head, router, useForm } from '@inertiajs/react'; +import { Pause, Play, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import Heading from '@/components/heading'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import AppLayout from '@/layouts/app-layout'; +import type { Agent, BreadcrumbItem, Routine } from '@/types'; + +const CRON_PRESETS: { label: string; value: string }[] = [ + { label: 'Every hour', value: '0 * * * *' }, + { label: 'Every 6 hours', value: '0 */6 * * *' }, + { label: 'Daily at midnight', value: '0 0 * * *' }, + { label: 'Daily at 9 AM', value: '0 9 * * *' }, + { label: 'Every Monday at 9 AM', value: '0 9 * * 1' }, + { label: 'Every weekday at 9 AM', value: '0 9 * * 1-5' }, + { label: 'First of every month', value: '0 0 1 * *' }, +]; + +const COMMON_TIMEZONES = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Paris', + 'Asia/Tokyo', + 'Asia/Shanghai', + 'Asia/Kolkata', + 'Australia/Sydney', +]; + +function humanCron(expr: string): string { + const map: Record = { + '* * * * *': 'Every minute', + '0 * * * *': 'Every hour', + '0 */6 * * *': 'Every 6 hours', + '0 0 * * *': 'Daily at midnight', + '0 9 * * *': 'Daily at 9 AM', + '0 9 * * 1': 'Every Monday at 9 AM', + '0 9 * * 1-5': 'Weekdays at 9 AM', + '0 0 1 * *': 'First of every month', + }; + return map[expr] ?? expr; +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function CreateRoutineDialog({ agents }: { agents: Agent[] }) { + const [open, setOpen] = useState(false); + const form = useForm({ + title: '', + description: '', + agent_id: '', + cron_expression: '0 9 * * *', + timezone: 'UTC', + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + form.post('/company/routines', { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + } + + return ( + + + + + + + Create Routine + + Schedule a recurring task for an agent. + + +
+
+ + + form.setData('title', e.target.value) + } + placeholder="e.g. Daily standup report" + required + /> + {form.errors.title && ( +

+ {form.errors.title} +

+ )} +
+
+ +