Merge pull request 'feat: add template version management' (#63) from feat/template-version-management into main
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s

This commit is contained in:
Charon 2026-02-24 13:25:33 +00:00
commit 80c778cb08
6 changed files with 533 additions and 3 deletions

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Create plan_template_versions table and add template_version_id to agent_plans.
*
* Template versions snapshot YAML template content at plan-creation time so
* existing plans are never affected when a template file is updated.
*
* Deduplication: identical content reuses the same version row (same content_hash).
*
* Guarded with hasTable()/hasColumn() so this migration is idempotent and
* can coexist with a consolidated app-level migration.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('plan_template_versions')) {
Schema::create('plan_template_versions', function (Blueprint $table) {
$table->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();
}
};

View file

@ -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
{

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Plan Template Version - immutable snapshot of a YAML template's content.
*
* When a plan is created from a template, the template content is snapshotted
* here so future edits to the YAML file do not affect existing plans.
*
* Identical content is deduplicated via content_hash so no duplicate rows
* accumulate when the same (unchanged) template is used repeatedly.
*
* @property int $id
* @property string $slug Template file slug (filename without extension)
* @property int $version Sequential version number per slug
* @property string $name Template name at snapshot time
* @property array $content Full template content as JSON
* @property string $content_hash SHA-256 of json_encode($content)
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class PlanTemplateVersion extends Model
{
protected $fillable = [
'slug',
'version',
'name',
'content',
'content_hash',
];
protected $casts = [
'content' => '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<int, static>
*/
public static function historyFor(string $slug): Collection
{
return static::where('slug', $slug)
->orderByDesc('version')
->get();
}
}

View file

@ -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<int, array{id: int, slug: string, version: int, name: string, content_hash: string, created_at: string}>
*/
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.
*/

View file

@ -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

View file

@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
/**
* Tests for template version management (FEAT-003).
*
* Covers:
* - Version snapshot creation when a plan is created from a template
* - Deduplication: identical template content reuses the same version row
* - New versions are created when template content changes
* - Plans reference their template version
* - Version history and retrieval via PlanTemplateService
*/
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\PlanTemplateVersion;
use Core\Mod\Agentic\Services\PlanTemplateService;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml;
// =========================================================================
// Setup helpers
// =========================================================================
beforeEach(function () {
$this->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');
});
});