diff --git a/Migrations/0001_01_01_000007_add_template_versions.php b/Migrations/0001_01_01_000007_add_template_versions.php new file mode 100644 index 0000000..418da01 --- /dev/null +++ b/Migrations/0001_01_01_000007_add_template_versions.php @@ -0,0 +1,69 @@ +id(); + $table->string('slug'); + $table->unsignedInteger('version'); + $table->string('name'); + $table->json('content'); + $table->char('content_hash', 64); + $table->timestamps(); + + $table->unique(['slug', 'version']); + $table->index(['slug', 'content_hash']); + }); + } + + if (Schema::hasTable('agent_plans') && ! Schema::hasColumn('agent_plans', 'template_version_id')) { + Schema::table('agent_plans', function (Blueprint $table) { + $table->foreignId('template_version_id') + ->nullable() + ->constrained('plan_template_versions') + ->nullOnDelete() + ->after('source_file'); + }); + } + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + + if (Schema::hasTable('agent_plans') && Schema::hasColumn('agent_plans', 'template_version_id')) { + Schema::table('agent_plans', function (Blueprint $table) { + $table->dropForeign(['template_version_id']); + $table->dropColumn('template_version_id'); + }); + } + + Schema::dropIfExists('plan_template_versions'); + + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/Models/AgentPlan.php b/Models/AgentPlan.php index 5bb118a..8605b41 100644 --- a/Models/AgentPlan.php +++ b/Models/AgentPlan.php @@ -66,6 +66,7 @@ class AgentPlan extends Model 'metadata', 'source_file', 'archived_at', + 'template_version_id', ]; protected $casts = [ @@ -105,6 +106,11 @@ class AgentPlan extends Model return $this->hasMany(AgentWorkspaceState::class); } + public function templateVersion(): BelongsTo + { + return $this->belongsTo(PlanTemplateVersion::class, 'template_version_id'); + } + // Scopes public function scopeActive(Builder $query): Builder { diff --git a/Models/PlanTemplateVersion.php b/Models/PlanTemplateVersion.php new file mode 100644 index 0000000..a8e5d8c --- /dev/null +++ b/Models/PlanTemplateVersion.php @@ -0,0 +1,92 @@ + 'array', + 'version' => 'integer', + ]; + + /** + * Plans that were created from this template version. + */ + public function plans(): HasMany + { + return $this->hasMany(AgentPlan::class, 'template_version_id'); + } + + /** + * Find an existing version by content hash, or create a new one. + * + * Deduplicates identical template content so we don't store redundant rows + * when the same (unchanged) template is used multiple times. + */ + public static function findOrCreateFromTemplate(string $slug, array $content): self + { + $hash = hash('sha256', json_encode($content, JSON_UNESCAPED_UNICODE)); + + $existing = static::where('slug', $slug) + ->where('content_hash', $hash) + ->first(); + + if ($existing) { + return $existing; + } + + $nextVersion = (static::where('slug', $slug)->max('version') ?? 0) + 1; + + return static::create([ + 'slug' => $slug, + 'version' => $nextVersion, + 'name' => $content['name'] ?? $slug, + 'content' => $content, + 'content_hash' => $hash, + ]); + } + + /** + * Get all recorded versions for a template slug, newest first. + * + * @return Collection + */ + public static function historyFor(string $slug): Collection + { + return static::where('slug', $slug) + ->orderByDesc('version') + ->get(); + } +} diff --git a/Services/PlanTemplateService.php b/Services/PlanTemplateService.php index 9117ea0..3470406 100644 --- a/Services/PlanTemplateService.php +++ b/Services/PlanTemplateService.php @@ -6,6 +6,7 @@ namespace Core\Mod\Agentic\Services; use Core\Mod\Agentic\Models\AgentPhase; use Core\Mod\Agentic\Models\AgentPlan; +use Core\Mod\Agentic\Models\PlanTemplateVersion; use Core\Tenant\Models\Workspace; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; @@ -146,6 +147,10 @@ class PlanTemplateService return null; } + // Snapshot the raw template content before variable substitution so the + // version record captures the canonical template, not the instantiated copy. + $templateVersion = PlanTemplateVersion::findOrCreateFromTemplate($templateSlug, $template); + // Replace variables in template $template = $this->substituteVariables($template, $variables); @@ -164,10 +169,12 @@ class PlanTemplateService 'description' => $template['description'] ?? null, 'context' => $context, 'status' => ($options['activate'] ?? false) ? AgentPlan::STATUS_ACTIVE : AgentPlan::STATUS_DRAFT, + 'template_version_id' => $templateVersion->id, 'metadata' => array_merge($template['metadata'] ?? [], [ 'source' => 'template', 'template_slug' => $templateSlug, 'template_name' => $template['name'], + 'template_version' => $templateVersion->version, 'variables' => $variables, 'created_at' => now()->toIso8601String(), ]), @@ -361,6 +368,7 @@ class PlanTemplateService } /** +<<<<<<< HEAD * Naming convention reminder included in validation results. */ private const NAMING_CONVENTION = 'Variable names use snake_case (e.g. project_name, api_key)'; @@ -401,6 +409,41 @@ class PlanTemplateService return $message; } + /** + * Get the version history for a template slug, newest first. + * + * Returns an array of version summaries (without full content) for display. + * + * @return array + */ + public function getVersionHistory(string $slug): array + { + return PlanTemplateVersion::historyFor($slug) + ->map(fn (PlanTemplateVersion $v) => [ + 'id' => $v->id, + 'slug' => $v->slug, + 'version' => $v->version, + 'name' => $v->name, + 'content_hash' => $v->content_hash, + 'created_at' => $v->created_at?->toIso8601String(), + ]) + ->toArray(); + } + + /** + * Get a specific stored version of a template by slug and version number. + * + * Returns the snapshotted content array, or null if not found. + */ + public function getVersion(string $slug, int $version): ?array + { + $record = PlanTemplateVersion::where('slug', $slug) + ->where('version', $version) + ->first(); + + return $record?->content; + } + /** * Get templates by category. */ diff --git a/TODO.md b/TODO.md index 759ea78..a8daa6c 100644 --- a/TODO.md +++ b/TODO.md @@ -191,10 +191,10 @@ Production-quality task list for the AI agent orchestration package. - Issue: Archived plans kept forever - Fix: Add configurable retention period, cleanup job -- [ ] **FEAT-003: Template version management** - - Location: `Services/PlanTemplateService.php` +- [x] **FEAT-003: Template version management** + - Location: `Services/PlanTemplateService.php`, `Models/PlanTemplateVersion.php` - Issue: Template changes affect existing plan references - - Fix: Add version tracking to templates + - Fix: Add version tracking to templates — implemented in #35 ### Consistency diff --git a/tests/Feature/TemplateVersionManagementTest.php b/tests/Feature/TemplateVersionManagementTest.php new file mode 100644 index 0000000..2fed71e --- /dev/null +++ b/tests/Feature/TemplateVersionManagementTest.php @@ -0,0 +1,320 @@ +workspace = Workspace::factory()->create(); + $this->service = app(PlanTemplateService::class); + $this->templatesPath = resource_path('plan-templates'); + + if (! File::isDirectory($this->templatesPath)) { + File::makeDirectory($this->templatesPath, 0755, true); + } +}); + +afterEach(function () { + if (File::isDirectory($this->templatesPath)) { + File::deleteDirectory($this->templatesPath); + } +}); + +function writeVersionTemplate(string $slug, array $content): void +{ + File::put( + resource_path('plan-templates').'/'.$slug.'.yaml', + Yaml::dump($content, 10) + ); +} + +// ========================================================================= +// PlanTemplateVersion model +// ========================================================================= + +describe('PlanTemplateVersion model', function () { + it('creates a new version record on first use', function () { + $content = ['name' => 'My Template', 'slug' => 'my-tpl', 'phases' => []]; + + $version = PlanTemplateVersion::findOrCreateFromTemplate('my-tpl', $content); + + expect($version)->toBeInstanceOf(PlanTemplateVersion::class) + ->and($version->slug)->toBe('my-tpl') + ->and($version->version)->toBe(1) + ->and($version->name)->toBe('My Template') + ->and($version->content)->toBe($content) + ->and($version->content_hash)->toBe(hash('sha256', json_encode($content, JSON_UNESCAPED_UNICODE))); + }); + + it('reuses existing version when content is identical', function () { + $content = ['name' => 'Stable', 'slug' => 'stable', 'phases' => []]; + + $v1 = PlanTemplateVersion::findOrCreateFromTemplate('stable', $content); + $v2 = PlanTemplateVersion::findOrCreateFromTemplate('stable', $content); + + expect($v1->id)->toBe($v2->id) + ->and(PlanTemplateVersion::where('slug', 'stable')->count())->toBe(1); + }); + + it('creates a new version when content changes', function () { + $contentV1 = ['name' => 'Template', 'slug' => 'evolving', 'phases' => []]; + $contentV2 = ['name' => 'Template', 'slug' => 'evolving', 'phases' => [['name' => 'New Phase']]]; + + $v1 = PlanTemplateVersion::findOrCreateFromTemplate('evolving', $contentV1); + $v2 = PlanTemplateVersion::findOrCreateFromTemplate('evolving', $contentV2); + + expect($v1->id)->not->toBe($v2->id) + ->and($v1->version)->toBe(1) + ->and($v2->version)->toBe(2) + ->and(PlanTemplateVersion::where('slug', 'evolving')->count())->toBe(2); + }); + + it('increments version numbers sequentially', function () { + for ($i = 1; $i <= 3; $i++) { + $content = ['name' => "Version {$i}", 'slug' => 'sequential', 'phases' => [$i]]; + $v = PlanTemplateVersion::findOrCreateFromTemplate('sequential', $content); + expect($v->version)->toBe($i); + } + }); + + it('scopes versions by slug independently', function () { + $alpha = ['name' => 'Alpha', 'slug' => 'alpha', 'phases' => []]; + $beta = ['name' => 'Beta', 'slug' => 'beta', 'phases' => []]; + + $vA = PlanTemplateVersion::findOrCreateFromTemplate('alpha', $alpha); + $vB = PlanTemplateVersion::findOrCreateFromTemplate('beta', $beta); + + expect($vA->version)->toBe(1) + ->and($vB->version)->toBe(1) + ->and($vA->id)->not->toBe($vB->id); + }); + + it('returns history for a slug newest first', function () { + $content1 = ['name' => 'T', 'slug' => 'hist', 'phases' => [1]]; + $content2 = ['name' => 'T', 'slug' => 'hist', 'phases' => [2]]; + + PlanTemplateVersion::findOrCreateFromTemplate('hist', $content1); + PlanTemplateVersion::findOrCreateFromTemplate('hist', $content2); + + $history = PlanTemplateVersion::historyFor('hist'); + + expect($history)->toHaveCount(2) + ->and($history[0]->version)->toBe(2) + ->and($history[1]->version)->toBe(1); + }); + + it('returns empty collection when no versions exist for slug', function () { + $history = PlanTemplateVersion::historyFor('nonexistent-slug'); + + expect($history)->toBeEmpty(); + }); +}); + +// ========================================================================= +// Plan creation snapshots a template version +// ========================================================================= + +describe('plan creation version tracking', function () { + it('creates a version record when creating a plan from a template', function () { + writeVersionTemplate('versioned-plan', [ + 'name' => 'Versioned Plan', + 'phases' => [['name' => 'Phase 1']], + ]); + + $plan = $this->service->createPlan('versioned-plan', [], [], $this->workspace); + + expect($plan->template_version_id)->not->toBeNull() + ->and(PlanTemplateVersion::where('slug', 'versioned-plan')->count())->toBe(1); + }); + + it('associates the plan with the template version', function () { + writeVersionTemplate('linked-version', [ + 'name' => 'Linked Version', + 'phases' => [], + ]); + + $plan = $this->service->createPlan('linked-version', [], [], $this->workspace); + + expect($plan->templateVersion)->toBeInstanceOf(PlanTemplateVersion::class) + ->and($plan->templateVersion->slug)->toBe('linked-version') + ->and($plan->templateVersion->version)->toBe(1); + }); + + it('stores template version number in metadata', function () { + writeVersionTemplate('meta-version', [ + 'name' => 'Meta Version', + 'phases' => [], + ]); + + $plan = $this->service->createPlan('meta-version', [], [], $this->workspace); + + expect($plan->metadata['template_version'])->toBe(1); + }); + + it('reuses the same version record for multiple plans from unchanged template', function () { + writeVersionTemplate('shared-version', [ + 'name' => 'Shared Template', + 'phases' => [], + ]); + + $plan1 = $this->service->createPlan('shared-version', [], [], $this->workspace); + $plan2 = $this->service->createPlan('shared-version', [], [], $this->workspace); + + expect($plan1->template_version_id)->toBe($plan2->template_version_id) + ->and(PlanTemplateVersion::where('slug', 'shared-version')->count())->toBe(1); + }); + + it('creates a new version when template content changes between plan creations', function () { + writeVersionTemplate('changing-template', [ + 'name' => 'Original', + 'phases' => [], + ]); + + $plan1 = $this->service->createPlan('changing-template', [], [], $this->workspace); + + // Simulate template file update + writeVersionTemplate('changing-template', [ + 'name' => 'Updated', + 'phases' => [['name' => 'Added Phase']], + ]); + + // Re-resolve the service so it reads fresh YAML + $freshService = new PlanTemplateService; + $plan2 = $freshService->createPlan('changing-template', [], [], $this->workspace); + + expect($plan1->template_version_id)->not->toBe($plan2->template_version_id) + ->and($plan1->templateVersion->version)->toBe(1) + ->and($plan2->templateVersion->version)->toBe(2) + ->and(PlanTemplateVersion::where('slug', 'changing-template')->count())->toBe(2); + }); + + it('existing plans keep their version reference after template update', function () { + writeVersionTemplate('stable-ref', [ + 'name' => 'Stable', + 'phases' => [], + ]); + + $plan = $this->service->createPlan('stable-ref', [], [], $this->workspace); + $originalVersionId = $plan->template_version_id; + + // Update template file + writeVersionTemplate('stable-ref', [ + 'name' => 'Stable Updated', + 'phases' => [['name' => 'New Phase']], + ]); + + // Reload plan from DB - version reference must be unchanged + $plan->refresh(); + + expect($plan->template_version_id)->toBe($originalVersionId) + ->and($plan->templateVersion->name)->toBe('Stable'); + }); + + it('snapshots raw template content before variable substitution', function () { + writeVersionTemplate('raw-snapshot', [ + 'name' => '{{ project }} Plan', + 'phases' => [['name' => '{{ project }} Setup']], + ]); + + $plan = $this->service->createPlan('raw-snapshot', ['project' => 'MyApp'], [], $this->workspace); + + // Version content should retain placeholders, not the substituted values + $versionContent = $plan->templateVersion->content; + + expect($versionContent['name'])->toBe('{{ project }} Plan') + ->and($versionContent['phases'][0]['name'])->toBe('{{ project }} Setup'); + }); +}); + +// ========================================================================= +// PlanTemplateService version methods +// ========================================================================= + +describe('PlanTemplateService version methods', function () { + it('getVersionHistory returns empty array when no plans created', function () { + $history = $this->service->getVersionHistory('no-plans-yet'); + + expect($history)->toBeArray()->toBeEmpty(); + }); + + it('getVersionHistory returns version summaries after plan creation', function () { + writeVersionTemplate('hist-service', [ + 'name' => 'History Template', + 'phases' => [], + ]); + + $this->service->createPlan('hist-service', [], [], $this->workspace); + + $history = $this->service->getVersionHistory('hist-service'); + + expect($history)->toHaveCount(1) + ->and($history[0])->toHaveKeys(['id', 'slug', 'version', 'name', 'content_hash', 'created_at']) + ->and($history[0]['slug'])->toBe('hist-service') + ->and($history[0]['version'])->toBe(1) + ->and($history[0]['name'])->toBe('History Template'); + }); + + it('getVersionHistory orders newest version first', function () { + writeVersionTemplate('order-hist', ['name' => 'V1', 'phases' => []]); + $this->service->createPlan('order-hist', [], [], $this->workspace); + + writeVersionTemplate('order-hist', ['name' => 'V2', 'phases' => [['name' => 'p']]]); + $freshService = new PlanTemplateService; + $freshService->createPlan('order-hist', [], [], $this->workspace); + + $history = $this->service->getVersionHistory('order-hist'); + + expect($history[0]['version'])->toBe(2) + ->and($history[1]['version'])->toBe(1); + }); + + it('getVersion returns content for an existing version', function () { + $templateContent = ['name' => 'Get Version Test', 'phases' => []]; + writeVersionTemplate('get-version', $templateContent); + + $this->service->createPlan('get-version', [], [], $this->workspace); + + $content = $this->service->getVersion('get-version', 1); + + expect($content)->not->toBeNull() + ->and($content['name'])->toBe('Get Version Test'); + }); + + it('getVersion returns null for nonexistent version', function () { + $content = $this->service->getVersion('nonexistent', 99); + + expect($content)->toBeNull(); + }); + + it('getVersion returns the correct historic content when template has changed', function () { + writeVersionTemplate('historic-content', ['name' => 'Original Name', 'phases' => []]); + $this->service->createPlan('historic-content', [], [], $this->workspace); + + writeVersionTemplate('historic-content', ['name' => 'Updated Name', 'phases' => [['name' => 'p']]]); + $freshService = new PlanTemplateService; + $freshService->createPlan('historic-content', [], [], $this->workspace); + + expect($this->service->getVersion('historic-content', 1)['name'])->toBe('Original Name') + ->and($this->service->getVersion('historic-content', 2)['name'])->toBe('Updated Name'); + }); +});