# VikonPartialUpdates Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement partial module updates (per-part: common, struct, document...) in VikonIntegration with full backend support and Vue UI. **Architecture:** Backend-driven polling — frontend sends one request, backend executes the 4-step flow (request → poll → check → download), returns result. Atomic swap with `_new`/`_old` suffixes for crash safety. Separate Action for orchestration, Task for polling loop. **Tech Stack:** Laravel 10, PHP 8.1+, Vue 3 Composition API, Inertia.js, PHPUnit ## Global Constraints - Porto Architecture: Route → Controller → Action → Task - All PHP code in `app/Containers/VikonIntegration/` - Tests in `app/Containers/VikonIntegration/Tests/Unit/` and `Tests/Feature/` - Docker constraint: PHP/composer commands via `docker exec ntspi-php` - Code in English, comments in English - No Options API in Vue — Composition API only --- ### Task 1: Add `atomicSwap()` to FilesystemTask **Covers:** [S3], [S7] **Files:** - Modify: `app/Containers/VikonIntegration/Tasks/FilesystemTask.php` - Test: `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php` **Interfaces:** - Consumes: existing `isPathSafe()` method - Produces: `atomicSwap(string $newEntryPath, string $currentEntryPath, string $baseDir, int $moduleId): bool` - [ ] **Step 1: Write the failing test** ```php tempDir = sys_get_temp_dir() . '/vikon_test_' . uniqid(); File::makeDirectory($this->tempDir, 0755, true, true); $this->fs = new FilesystemTask(); } protected function tearDown(): void { File::deleteDirectory($this->tempDir); parent::tearDown(); } public function test_atomic_swap_replaces_existing_entry(): void { $base = $this->tempDir . '/module'; File::makeDirectory($base, 0755, true, true); // Current entry exists file_put_contents($base . '/common/index.php', 'tempDir . '/extracted/common'; File::makeDirectory($newDir, 0755, true, true); file_put_contents($newDir . '/index.php', 'fs->atomicSwap( $newDir, $base . '/common', $base, 1 ); $this->assertTrue($result); $this->assertFileExists($base . '/common/index.php'); $this->assertStringContainsString('new', file_get_contents($base . '/common/index.php')); $this->assertDirectoryExists($base . '/common_old'); $this->assertDirectoryDoesNotExist($base . '/common_new'); } public function test_atomic_swap_creates_new_entry_when_not_exists(): void { $base = $this->tempDir . '/module'; File::makeDirectory($base, 0755, true, true); $newDir = $this->tempDir . '/extracted/assets'; File::makeDirectory($newDir, 0755, true, true); file_put_contents($newDir . '/style.css', 'body {}'); $result = $this->fs->atomicSwap( $newDir, $base . '/assets', $base, 1 ); $this->assertTrue($result); $this->assertFileExists($base . '/assets/style.css'); $this->assertDirectoryDoesNotExist($base . '/assets_old'); } public function test_atomic_swap_returns_false_on_path_traversal(): void { $result = $this->fs->atomicSwap( '/etc/passwd', $this->tempDir . '/module/etc', $this->tempDir . '/module', 1 ); $this->assertFalse($result); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest` Expected: FAIL with "Call to undefined method atomicSwap" - [ ] **Step 3: Write minimal implementation** Add to `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`: ```php public function atomicSwap( string $newEntryPath, string $currentEntryPath, string $baseDir, int $moduleId ): bool { if (!$this->isPathSafe($newEntryPath, $baseDir) || !$this->isPathSafe($currentEntryPath, $baseDir)) { return false; } if (!File::exists($newEntryPath)) { return false; } $isFile = is_file($newEntryPath); $newPostfix = $currentEntryPath . '_new'; $oldPostfix = $currentEntryPath . '_old'; if (File::exists($currentEntryPath)) { // Step 1: Remove stale _new if exists if (File::exists($newPostfix)) { $isFile ? File::delete($newPostfix) : File::deleteDirectory($newPostfix); } // Step 2: Move new → _new if (!$this->moveEntry($newEntryPath, $newPostfix, $isFile)) { return false; } // Step 3: Remove stale _old if exists if (File::exists($oldPostfix)) { $isFile ? File::delete($oldPostfix) : File::deleteDirectory($oldPostfix); } // Step 4: Move current → _old if (!$this->moveEntry($currentEntryPath, $oldPostfix, $isFile)) { return false; } // Step 5: Move _new → current return $this->moveEntry($newPostfix, $currentEntryPath, $isFile); } // Entry doesn't exist — move directly return $this->moveEntry($newEntryPath, $currentEntryPath, $isFile); } private function moveEntry(string $source, string $dest, bool $isFile): bool { if ($isFile) { $parent = dirname($dest); if (!File::isDirectory($parent)) { File::makeDirectory($parent, 0755, true, true); } return rename($source, $dest); } return rename($source, $dest); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest` Expected: PASS - [ ] **Step 5: Commit** ```bash git add app/Containers/VikonIntegration/Tasks/FilesystemTask.php app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php git commit -m "feat(vikon): add atomicSwap to FilesystemTask for crash-safe updates" ``` --- ### Task 2: Create PollPartStatusTask **Covers:** [S3], [S8] **Files:** - Create: `app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php` - Test: `app/Containers/VikonIntegration/Tests/Unit/PollPartStatusTaskTest.php` **Interfaces:** - Consumes: `HttpTask::getWithToken()` - Produces: `run(string $operationIdentity, string $accessToken): array` returning `['status' => 'completed'|'failed'|'timeout']` - [ ] **Step 1: Write the failing test** ```php shouldReceive('json')->once()->andReturn(['status' => 'completed']); $http->shouldReceive('getWithToken') ->once() ->with( Mockery::on(fn($endpoint) => str_contains($endpoint, 'getStatusPartGeneration')), 'test-token', 'api' ) ->andReturn($response); $task = new PollPartStatusTask($http, 3, 50); $result = $task->run('op-123', 'test-token'); $this->assertEquals('completed', $result['status']); } public function test_returns_failed_when_status_is_failed(): void { $http = Mockery::mock(HttpTask::class); $response = Mockery::mock(); $response->shouldReceive('json')->once()->andReturn([ 'status' => 'failed', 'message' => 'Generation error', ]); $http->shouldReceive('getWithToken') ->once() ->andReturn($response); $task = new PollPartStatusTask($http, 3, 50); $result = $task->run('op-123', 'test-token'); $this->assertEquals('failed', $result['status']); $this->assertEquals('Generation error', $result['error'] ?? null); } public function test_returns_timeout_after_max_attempts(): void { $http = Mockery::mock(HttpTask::class); $response = Mockery::mock(); $response->shouldReceive('json')->andReturn(['status' => 'pending']); $http->shouldReceive('getWithToken') ->times(3) ->andReturn($response); $task = new PollPartStatusTask($http, 0, 3); // interval=0, max=3 $result = $task->run('op-123', 'test-token'); $this->assertEquals('timeout', $result['status']); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `docker exec ntspi-php php artisan test --filter=PollPartStatusTaskTest` Expected: FAIL with "Class not found" - [ ] **Step 3: Write minimal implementation** Create `app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php`: ```php maxAttempts; $attempt++) { $response = $this->http->getWithToken( "pull_updates/getStatusPartGenerationByNewCoreJson?operation_identity={$operationIdentity}", $accessToken ); $body = $response->json(); $status = $body['status'] ?? 'unknown'; Log::info('Vikon poll part status', [ 'operation' => $operationIdentity, 'status' => $status, 'attempt' => $attempt + 1, ]); if ($status === 'completed') { return ['status' => 'completed']; } if ($status === 'failed') { return [ 'status' => 'failed', 'error' => $body['message'] ?? 'Unknown error', ]; } if ($attempt < $this->maxAttempts - 1) { sleep($this->interval); } } return ['status' => 'timeout']; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `docker exec ntspi-php php artisan test --filter=PollPartStatusTaskTest` Expected: PASS - [ ] **Step 5: Commit** ```bash git add app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php app/Containers/VikonIntegration/Tests/Unit/PollPartStatusTaskTest.php git commit -m "feat(vikon): add PollPartStatusTask for part update polling" ``` --- ### Task 3: Add parts config to vikon.php **Covers:** [S8] **Files:** - Modify: `config/vikon.php` - Modify: `app/Containers/VikonIntegration/Providers/VikonServiceProvider.php` (if needed for new config) **Interfaces:** - Consumes: existing config structure - Produces: `config('vikon.parts')`, `config('vikon.poll_interval')`, `config('vikon.poll_max_attempts')` - [ ] **Step 1: Read current config** Read `config/vikon.php` to see current structure. - [ ] **Step 2: Add parts configuration** Add to `config/vikon.php` before the closing `];`: ```php 'parts' => [ 1 => ['common', 'struct', 'document', 'education', 'managers', 'employees', 'objects', 'paid_edu', 'budget', 'vacant', 'grants', 'inter', 'catering', 'eduStandarts', 'corruption', 'antiterrorism'], 2 => ['abitur'], 6 => ['general', 'structure', 'faq', 'procedures', 'results-and-reports', 'plans', 'survey'], ], 'poll_interval' => (int) env('VIKON_POLL_INTERVAL', 3), 'poll_max_attempts' => (int) env('VIKON_POLL_MAX_ATTEMPTS', 50), ``` - [ ] **Step 3: Verify config loads** Run: `docker exec ntspi-php php artisan tinker --execute="dump(config('vikon.parts'));"` Expected: Array with module IDs as keys - [ ] **Step 4: Commit** ```bash git add config/vikon.php git commit -m "feat(vikon): add parts config for partial updates" ``` --- ### Task 4: Create UpdatePartAction **Covers:** [S3], [S5], [S6], [S7] **Files:** - Create: `app/Containers/VikonIntegration/Actions/UpdatePartAction.php` - Test: `app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php` **Interfaces:** - Consumes: `HttpTask`, `FilesystemTask::atomicSwap()`, `PollPartStatusTask` - Produces: `run(int $moduleId, string $part, string $accessToken): array` - [ ] **Step 1: Write the failing test** ```php tempDir = sys_get_temp_dir() . '/vikon_update_test_' . uniqid(); File::makeDirectory($this->tempDir, 0755, true, true); } protected function tearDown(): void { File::deleteDirectory($this->tempDir); Mockery::close(); parent::tearDown(); } public function test_rejects_invalid_module_id(): void { $http = Mockery::mock(HttpTask::class); $fs = Mockery::mock(FilesystemTask::class); $poll = Mockery::mock(PollPartStatusTask::class); $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], ]); $this->expectException(\RuntimeException::class); $action->run(999, 'common', 'token'); } public function test_rejects_invalid_part(): void { $http = Mockery::mock(HttpTask::class); $fs = Mockery::mock(FilesystemTask::class); $poll = Mockery::mock(PollPartStatusTask::class); $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], ]); $this->expectException(\RuntimeException::class); $action->run(1, 'nonexistent', 'token'); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `docker exec ntspi-php php artisan test --filter=UpdatePartActionTest` Expected: FAIL with "Class not found" - [ ] **Step 3: Write minimal implementation** Create `app/Containers/VikonIntegration/Actions/UpdatePartAction.php`: ```php modulesConfig[$moduleId] ?? throw new \RuntimeException("Unknown module: {$moduleId}"); if (!in_array($part, config('vikon.parts', [])[$moduleId] ?? [], true)) { throw new \RuntimeException("Invalid part '{$part}' for module {$moduleId}"); } Log::info('Vikon: starting part update', ['module' => $moduleId, 'part' => $part]); // Step 1: Request generation $genResponse = $this->http->postWithToken( 'pull_updates/requestGeneratePartByNewCoreJson', $accessToken, ['part' => $part] ); $genBody = $genResponse->json(); if (empty($genBody['operation_identity'])) { throw new \RuntimeException('Failed to request part generation: ' . ($genBody['message'] ?? 'Unknown')); } $operationIdentity = $genBody['operation_identity']; $ttl = $genBody['ttl'] ?? 120; Log::info('Vikon: part generation requested', ['operation' => $operationIdentity, 'ttl' => $ttl]); // Step 2: Poll status $pollResult = $this->pollStatus->run($operationIdentity, $accessToken); if ($pollResult['status'] !== 'completed') { $error = $pollResult['error'] ?? $pollResult['status']; throw new \RuntimeException("Part generation failed: {$error}"); } // Step 3: Check result $checkResponse = $this->http->postWithToken( 'pull_updates/checkPartGenerationByNewCoreResultJson', $accessToken, ['operation_identity' => $operationIdentity, 'part' => $part] ); $checkBody = $checkResponse->json(); if (empty($checkBody['success'])) { throw new \RuntimeException('Part not ready: ' . ($checkBody['message'] ?? 'Unknown')); } // Step 4: Download and apply $zipContent = $this->http->downloadWithToken( "pull_updates/downloadPartByNewCoreResult?operation_identity={$operationIdentity}&part={$part}", $accessToken ); $tempPath = $this->storagePath . '/temp/' . $config['path'] . '_part'; File::makeDirectory($tempPath, 0755, true, true); $zipFile = $tempPath . '/part.zip'; file_put_contents($zipFile, $zipContent); $zip = new ZipArchive(); if ($zip->open($zipFile) !== true) { throw new \RuntimeException('Failed to open part ZIP'); } $zip->extractTo($tempPath); $zip->close(); File::delete($zipFile); $modulePath = $this->basePath . '/' . $config['path']; $moduleConfig = $this->modulesConfig[$moduleId]; // Determine which module owns this part $ownerModuleId = $moduleId; if ($part !== 'abitur') { // For non-ABITUR parts, apply to the module that owns them $ownerModuleId = $moduleId; } $syncedCount = $this->applyPart($part, $tempPath, $modulePath, $ownerModuleId, $moduleConfig); // Clean temp File::deleteDirectory($tempPath); Log::info('Vikon: part update complete', ['module' => $moduleId, 'part' => $part, 'synced' => $syncedCount]); return [ 'success' => true, "message" => "Part '{$part}' updated successfully.", 'synced_count' => $syncedCount, ]; } private function applyPart( string $part, string $tempPath, string $modulePath, int $moduleId, array $moduleConfig ): int { if ($part === 'abitur') { return $this->applyAbiturPart($tempPath, $modulePath, $moduleId, $moduleConfig); } return $this->applyRegularPart($part, $tempPath, $modulePath, $moduleId, $moduleConfig); } private function applyRegularPart( string $part, string $tempPath, string $modulePath, int $moduleId, array $moduleConfig ): int { $partSource = $tempPath . '/' . $part; $partTarget = $modulePath . '/' . $part; if (!File::exists($partSource)) { // ZIP might have the part nested under a directory $extractedDirs = File::directories($tempPath); if (!empty($extractedDirs)) { $partSource = $extractedDirs[0] . '/' . $part; } } if (!File::exists($partSource)) { throw new \RuntimeException("Part directory not found in ZIP: {$part}"); } $result = $this->fs->atomicSwap($partSource, $partTarget, $modulePath, $moduleId); if (!$result) { $this->fs->restoreAfterFail($modulePath, [$part], $moduleId); throw new \RuntimeException("Failed to apply part: {$part}"); } return 1; } private function applyAbiturPart( string $tempPath, string $modulePath, int $moduleId, array $moduleConfig ): int { $abiturSource = $tempPath . '/abitur'; if (!File::exists($abiturSource)) { $extractedDirs = File::directories($tempPath); if (!empty($extractedDirs)) { $abiturSource = $extractedDirs[0] . '/abitur'; } } if (!File::exists($abiturSource)) { throw new \RuntimeException('ABITUR directory not found in ZIP'); } $entries = File::allFiles($abiturSource); $synced = 0; foreach ($entries as $file) { $relative = ltrim(str_replace($abiturSource, '', $file->getPathname()), '/'); $targetPath = $modulePath . '/' . $relative; $targetDir = dirname($targetPath); if (!File::isDirectory($targetDir)) { File::makeDirectory($targetDir, 0755, true, true); } $result = $this->fs->atomicSwap( $file->getPathname(), $targetPath, $modulePath, $moduleId ); if (!$result) { $this->fs->restoreAfterFail($modulePath, ['abitur'], $moduleId); throw new \RuntimeException("Failed to sync ABITUR file: {$relative}"); } $synced++; } return $synced; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `docker exec ntspi-php php artisan test --filter=UpdatePartActionTest` Expected: PASS - [ ] **Step 5: Commit** ```bash git add app/Containers/VikonIntegration/Actions/UpdatePartAction.php app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php git commit -m "feat(vikon): add UpdatePartAction for partial module updates" ``` --- ### Task 5: Add `restoreAfterFail()` to FilesystemTask **Covers:** [S7] **Files:** - Modify: `app/Containers/VikonIntegration/Tasks/FilesystemTask.php` - Test: `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php` (add test) **Interfaces:** - Consumes: existing `isPathSafe()`, `atomicSwap()` - Produces: `restoreAfterFail(string $modulePath, array $allowedEntries, int $moduleId): bool` - [ ] **Step 1: Add failing test** Append to `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php`: ```php public function test_restore_after_fail_removes_new_and_restores_old(): void { $base = $this->tempDir . '/module'; File::makeDirectory($base, 0755, true, true); // Simulate partial swap state: original exists, _old has backup file_put_contents($base . '/common/index.php', 'fs->restoreAfterFail($base, ['common'], 1); $this->assertTrue($result); $this->assertFileExists($base . '/common/index.php'); $this->assertStringContainsString('original', file_get_contents($base . '/common/index.php')); $this->assertDirectoryDoesNotExist($base . '/common_old'); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest::test_restore_after_fail` Expected: FAIL with "Call to undefined method restoreAfterFail" - [ ] **Step 3: Write implementation** Add to `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`: ```php public function restoreAfterFail(string $modulePath, array $allowedEntries, int $moduleId): bool { $entries = File::directories($modulePath); $entries = array_merge($entries, File::files($modulePath)); foreach ($entries as $entryPath) { $name = basename($entryPath); // Remove _new entries (they were never applied) if (str_ends_with($name, '_new')) { $baseName = substr($name, 0, -4); if (in_array($baseName, $allowedEntries, true)) { if (is_dir($entryPath)) { File::deleteDirectory($entryPath); } else { File::delete($entryPath); } } } // Restore _old entries back to original name if (str_ends_with($name, '_old')) { $baseName = substr($name, 0, -4); if (in_array($baseName, $allowedEntries, true)) { $originalPath = $modulePath . '/' . $baseName; if (File::exists($originalPath)) { if (is_dir($originalPath)) { File::deleteDirectory($originalPath); } else { File::delete($originalPath); } } rename($entryPath, $originalPath); } } } return true; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest` Expected: PASS (all 4 tests) - [ ] **Step 5: Commit** ```bash git add app/Containers/VikonIntegration/Tasks/FilesystemTask.php app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php git commit -m "feat(vikon): add restoreAfterFail for crash recovery in FilesystemTask" ``` --- ### Task 6: Create UpdatePartRequest and Controller route **Covers:** [S5] **Files:** - Create: `app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php` - Modify: `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php` - Modify: `app/Containers/VikonIntegration/UI/WEB/Routes/web.php` **Interfaces:** - Consumes: `UpdatePartAction::run()` - Produces: `POST /dashboard/vikon-updates/update-part` endpoint - [ ] **Step 1: Create UpdatePartRequest** Create `app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php`: ```php ['required', 'integer', 'in:1,2,6'], 'part' => ['required', 'string', 'max:50'], ]; } } ``` - [ ] **Step 2: Add updatePart method to VikonController** Add to `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php`: ```php use App\Containers\VikonIntegration\Actions\UpdatePartAction; use App\Containers\VikonIntegration\UI\WEB\Requests\UpdatePartRequest; ``` Add property and constructor injection: ```php private readonly UpdatePartAction $updatePart, ``` Add method: ```php public function updatePart(UpdatePartRequest $request): JsonResponse { $token = Session::get('vikon_access_token'); if (!$token) { return response()->json(['success' => false, 'requires_auth' => true], 401); } try { $result = $this->updatePart->run( $request->validated('module_id'), $request->validated('part'), $token ); return response()->json($result); } catch (\Throwable $e) { Log::error('Vikon part update failed', ['error' => $e->getMessage()]); return response()->json(['success' => false, 'message' => $e->getMessage()], 500); } } ``` - [ ] **Step 3: Add route** Add to `app/Containers/VikonIntegration/UI/WEB/Routes/web.php`: ```php Route::post('/update-part', [VikonController::class, 'updatePart'])->name('update-part'); ``` - [ ] **Step 4: Verify route exists** Run: `docker exec ntspi-php php artisan route:list --name=dashboard.vikon-updates` Expected: Shows `update-part` route - [ ] **Step 5: Commit** ```bash git add app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php app/Containers/VikonIntegration/UI/WEB/Routes/web.php git commit -m "feat(vikon): add updatePart endpoint with request validation" ``` --- ### Task 7: Update Vue frontend with part selection UI **Covers:** [S4] **Files:** - Modify: `resources/js/Pages/Dashboard/VikonUpdates/Index.vue` - Modify: `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php` (add parts to props) **Interfaces:** - Consumes: `POST /dashboard/vikon-updates/update-part` endpoint - Produces: UI with module/part selection and update button - [ ] **Step 1: Add parts to controller props** In `VikonController::index()` and `oauthCallback()`, add to the inertia render: ```php 'parts' => config('vikon.parts'), ``` - [ ] **Step 2: Update Index.vue** Read current `resources/js/Pages/Dashboard/VikonUpdates/Index.vue` and add: 1. New prop: `parts` 2. New reactive state: `selectedPart`, `updatingPart`, `partResult` 3. New method: `updatePart(moduleId, part)` 4. UI section: dropdown for part selection per module, update button, progress indicator Key additions to the `