Truth File strategy: never seed from latest, calibrate or detect

CheckModpackUpdates:
- Reads .modpack-checker.json Truth File from server filesystem
- Falls back to manifest.json, extracts fileID, writes Truth File
- NEVER seeds current_version from latest API result
- Unknown version → status: pending_calibration (not up_to_date)
- Removed seedCurrentVersion heuristic — replaced with Truth File
- writeTruthFile() helper writes .modpack-checker.json via Wings

ModpackAPIController:
- calibrate() now writes Truth File after DB update
- Persists across server reinstalls and cron runs

wrapper.tsx:
- pending_calibration: shows "Version unknown" + "Identify Version" button
- Ignored servers: muted card with "Resume" button (not hidden)
- Extracted renderCalibrateDropdown() for reuse
- Error state shows message instead of vanishing

Migration:
- Updates existing unknown+detected rows to pending_calibration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Chronicler #83 - The Compiler)
2026-04-13 06:09:05 -05:00
parent f39b6d6c67
commit 27b2744786
5 changed files with 453 additions and 64 deletions

View File

@@ -240,7 +240,7 @@ class CheckModpackUpdates extends Command
$latestVersion = $latestData['version'] ?? 'Unknown';
$latestFileId = $latestData['file_id'] ?? null;
// Get current_version: manifest > egg variable > existing DB > seed with latest
// Get current_version: manifest arg > egg variable > existing DB
$currentVersion = $installedVersion;
$currentFileId = null;
@@ -257,11 +257,53 @@ class CheckModpackUpdates extends Command
}
$currentFileId = $existing->current_file_id ?? null;
// First time — try date-time seeding, fall back to latest
if (empty($currentVersion)) {
$seeded = $this->seedCurrentVersion($server, $platform, $modpackId, $latestVersion, $latestFileId);
$currentVersion = $seeded['version'];
$currentFileId = $seeded['file_id'];
// Truth File: read .modpack-checker.json from server filesystem
if (empty($currentFileId)) {
try {
$this->fileRepository->setServer($server);
$truthRaw = $this->fileRepository->getContent('.modpack-checker.json');
$truthFile = json_decode($truthRaw, true);
if (!empty($truthFile['file_id'])) {
$currentFileId = $truthFile['file_id'];
$currentVersion = $currentVersion ?: ($truthFile['version'] ?? null);
$this->line(" [truth] Read file_id {$currentFileId} from .modpack-checker.json");
}
} catch (\Exception $e) {
// Truth File doesn't exist yet
}
}
// Legacy manifest.json: extract fileID, write Truth File
if (empty($currentFileId)) {
try {
$this->fileRepository->setServer($server);
$manifest = json_decode($this->fileRepository->getContent('manifest.json'), true);
if (!empty($manifest['files'][0]['fileID'])) {
$currentFileId = (string) $manifest['files'][0]['fileID'];
$this->writeTruthFile($server, $modpackId, $currentFileId, $manifest['version'] ?? null);
$currentVersion = $currentVersion ?: ($manifest['version'] ?? null);
$this->line(" [manifest] Extracted file_id {$currentFileId}, wrote Truth File");
}
} catch (\Exception $e) {
// No manifest
}
}
// NEVER seed from latest — if unknown, mark pending_calibration
if (empty($currentVersion) && empty($currentFileId)) {
$this->updateDatabase($server, [
'platform' => $platform,
'modpack_id' => $modpackId,
'modpack_name' => $latestData['name'],
'latest_version' => $latestVersion,
'latest_file_id' => $latestFileId,
'status' => 'pending_calibration',
'detection_method' => $method,
'error_message' => null,
'last_checked' => now(),
]);
$this->info(" ⏳ PENDING: {$latestData['name']} — calibration required");
return;
}
// Compare: prefer file ID, fall back to string
@@ -328,40 +370,22 @@ class CheckModpackUpdates extends Command
}
/**
* Seed current version using date-time heuristic.
* Finds the release closest to (but not after) the server's created_at date.
* Write .modpack-checker.json Truth File to server filesystem.
*/
private function seedCurrentVersion(Server $server, string $platform, string $modpackId, string $fallbackVersion, ?string $fallbackFileId): array
private function writeTruthFile(Server $server, string $projectId, string $fileId, ?string $version): void
{
try {
$installDate = $server->created_at?->toISOString();
if (!$installDate) {
return ['version' => $fallbackVersion, 'file_id' => $fallbackFileId];
}
$history = $this->apiService->fetchFileHistory($platform, $modpackId);
if (empty($history)) {
return ['version' => $fallbackVersion, 'file_id' => $fallbackFileId];
}
// Find the release closest to but not after install date
$matched = collect($history)
->filter(fn($f) => !empty($f['release_date']) && $f['release_date'] <= $installDate)
->sortByDesc('release_date')
->first();
if ($matched) {
$this->line(" [seed] Matched version {$matched['version']} (released {$matched['release_date']}) to install date {$installDate}");
return [
'version' => $matched['version'],
'file_id' => $matched['file_id'] ?? $fallbackFileId,
];
}
$this->fileRepository->setServer($server);
$this->fileRepository->putContent('.modpack-checker.json', json_encode([
'extension' => 'modpackchecker',
'project_id' => $projectId,
'file_id' => $fileId,
'version' => $version,
'calibrated_at' => now()->toIso8601String(),
], JSON_PRETTY_PRINT));
} catch (\Exception $e) {
$this->line(" [seed] Heuristic failed: " . $e->getMessage());
\Log::warning('[MVC] Could not write Truth File: ' . $e->getMessage());
}
return ['version' => $fallbackVersion, 'file_id' => $fallbackFileId];
}
private function getVariable(Server $server, string $name): ?string

View File

@@ -394,6 +394,21 @@ class ModpackAPIController extends Controller
'status' => $updateAvailable ? 'update_available' : 'up_to_date',
]);
// Write Truth File to server filesystem
$modpackId = $cached->modpack_id ?? '';
try {
$this->fileRepository->setServer($server);
$this->fileRepository->putContent('.modpack-checker.json', json_encode([
'extension' => 'modpackchecker',
'project_id' => $modpackId,
'file_id' => $fileId,
'version' => $version,
'calibrated_at' => now()->toIso8601String(),
], JSON_PRETTY_PRINT));
} catch (\Exception $e) {
\Log::warning('[MVC] Could not write Truth File on calibrate: ' . $e->getMessage());
}
return response()->json([
'success' => true,
'current_version' => $version,

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Documents the new 'pending_calibration' status value.
* The status column is VARCHAR so no schema change needed —
* this migration updates any 'unknown' status rows that have
* a detected platform but no current_version.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('modpackchecker_servers')
->where('status', 'unknown')
->whereNotNull('platform')
->whereNull('current_version')
->update(['status' => 'pending_calibration']);
}
public function down(): void
{
DB::table('modpackchecker_servers')
->where('status', 'pending_calibration')
->update(['status' => 'unknown']);
}
};

View File

@@ -106,7 +106,30 @@ const ModpackVersionCard: React.FC = () => {
};
if (loading) return null;
if (data?.is_ignored) return null;
// Muted card for ignored servers (with Resume button)
if (data?.is_ignored) {
return (
<div className={classNames(
'rounded shadow-lg bg-gray-600 opacity-50',
'col-span-3 md:col-span-2 lg:col-span-6',
'px-3 py-2 md:p-3 lg:p-4 mt-2'
)}>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center'}>
<FontAwesomeIcon icon={faCube} className={'w-4 h-4 mr-2 text-gray-400'} />
<span className={'text-gray-400 text-sm'}>
{data.modpack_name || 'Modpack'} Updates ignored
</span>
</div>
<button onClick={toggleIgnore}
className={'text-xs px-3 py-1 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded transition-colors'}>
Resume
</button>
</div>
</div>
);
}
if (error && !data) return (
<div className={classNames(
@@ -118,6 +141,67 @@ const ModpackVersionCard: React.FC = () => {
</div>
);
// Calibrate dropdown (reusable)
const renderCalibrateDropdown = () => (
<div className={'mt-2 bg-gray-800 rounded p-2 max-h-48 overflow-y-auto'}>
<p className={'text-xs text-gray-400 mb-1'}>Select your installed version:</p>
{loadingReleases && <p className={'text-xs text-gray-500'}>Loading releases...</p>}
{!loadingReleases && releases.length === 0 && (
<p className={'text-xs text-gray-500'}>No releases found</p>
)}
{releases.map((r) => (
<button key={r.file_id} onClick={() => selectRelease(r)}
className={classNames(
'block w-full text-left px-2 py-1 text-sm rounded',
'hover:bg-gray-700 text-gray-200',
data?.current_version === r.version ? 'bg-gray-700 text-cyan-300' : ''
)}>
{r.display_name}
{r.release_date && (
<span className={'text-gray-500 text-xs ml-2'}>
{new Date(r.release_date).toLocaleDateString()}
</span>
)}
</button>
))}
<button onClick={() => setShowCalibrate(false)}
className={'mt-1 text-xs text-gray-500 hover:text-gray-300'}>
Cancel
</button>
</div>
);
// Pending calibration — show "Identify Version" prompt
if (data && !data.configured && data.modpack_name) {
return (
<div className={classNames(
'rounded shadow-lg bg-gray-600',
'col-span-3 md:col-span-2 lg:col-span-6',
'px-3 py-2 md:p-3 lg:p-4 mt-2'
)}>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center'}>
<FontAwesomeIcon icon={faCube} className={'w-4 h-4 mr-2 text-gray-400'} />
<span className={'text-gray-400 text-sm'}>
{data.modpack_name} Version unknown
</span>
</div>
<div className={'flex gap-1'}>
<button onClick={openCalibrate}
className={'text-xs px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors'}>
Identify Version
</button>
<button onClick={toggleIgnore}
className={'text-gray-400 hover:text-gray-200 p-1'} title={'Hide'}>
<FontAwesomeIcon icon={faEyeSlash} className={'w-3 h-3'} />
</button>
</div>
</div>
{showCalibrate && renderCalibrateDropdown()}
</div>
);
}
const hasUpdate = data.update_available;
const configured = data.configured;
@@ -190,34 +274,7 @@ const ModpackVersionCard: React.FC = () => {
</div>
{/* Calibrate dropdown */}
{showCalibrate && (
<div className={'mt-2 bg-gray-800 rounded p-2 max-h-48 overflow-y-auto'}>
<p className={'text-xs text-gray-400 mb-1'}>Select your installed version:</p>
{loadingReleases && <p className={'text-xs text-gray-500'}>Loading releases...</p>}
{!loadingReleases && releases.length === 0 && (
<p className={'text-xs text-gray-500'}>No releases found</p>
)}
{releases.map((r) => (
<button key={r.file_id} onClick={() => selectRelease(r)}
className={classNames(
'block w-full text-left px-2 py-1 text-sm rounded',
'hover:bg-gray-700 text-gray-200',
data.current_version === r.version ? 'bg-gray-700 text-cyan-300' : ''
)}>
{r.display_name}
{r.release_date && (
<span className={'text-gray-500 text-xs ml-2'}>
{new Date(r.release_date).toLocaleDateString()}
</span>
)}
</button>
))}
<button onClick={() => setShowCalibrate(false)}
className={'mt-1 text-xs text-gray-500 hover:text-gray-300'}>
Cancel
</button>
</div>
)}
{showCalibrate && renderCalibrateDropdown()}
</div>
);
};