= 32) return $secret; } if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true); $secret = bin2hex(random_bytes(32)); $handle = @fopen($file, 'c+'); if ($handle === false || !flock($handle, LOCK_EX)) throw new RuntimeException('Could not store the agent secret.'); $existing = trim((string)stream_get_contents($handle)); if (strlen($existing) >= 32) { flock($handle, LOCK_UN); fclose($handle); return $existing; } ftruncate($handle, 0); fwrite($handle, $secret); fflush($handle); @chmod($file, 0600); flock($handle, LOCK_UN); fclose($handle); return $secret; } /** Undo token: change set id and a signature that survives a logout. */ function raijuAgentUndoToken(string $csId): string { return $csId . '.' . hash_hmac('sha256', 'undo:' . $csId, raijuAgentSecret()); } function raijuAgentVerifyUndoToken(string $csId, string $token): bool { return hash_equals(raijuAgentUndoToken($csId), $token); } // --------------------------------------------------------------------------- // Locks // --------------------------------------------------------------------------- /** @return resource|false */ function raijuAgentGlobalLock(int $waitSeconds = 10) { $file = raijuAgentDir() . '/apply.lock'; if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true); $handle = @fopen($file, 'c'); if ($handle === false) return false; $deadline = microtime(true) + $waitSeconds; do { if (flock($handle, LOCK_EX | LOCK_NB)) return $handle; usleep(50000); } while (microtime(true) < $deadline); fclose($handle); return false; } /** Locks for files in sorted order, so two writers can never wait for each other. */ function raijuAgentLockFiles(array $files): array { $files = array_values(array_unique($files)); sort($files); $handles = []; foreach ($files as $file) { $handle = raijuJsonLock($file); if ($handle === false) { raijuAgentUnlock($handles); throw new RuntimeException('Could not lock ' . basename($file) . '.'); } $handles[] = $handle; } return $handles; } function raijuAgentUnlock(array $handles): void { foreach ($handles as $handle) { if (is_resource($handle)) { flock($handle, LOCK_UN); fclose($handle); } } } // --------------------------------------------------------------------------- // Backups // --------------------------------------------------------------------------- function raijuAgentBackupDir(): string { return defined('BACKUP_PATH') ? rtrim((string)BACKUP_PATH, '/') : raijuAgentSiteRoot() . '/backups'; } /** Copy an existing content file to backups/ with the name the editors use for it. */ function raijuAgentBackupFile(string $file): void { if (!is_file($file)) return; $dir = raijuAgentBackupDir(); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return; $base = basename($file, '.json'); $kind = raijuAgentTargetKindOfFile($file); $prefix = $kind === 'news' ? 'news_' . $base : ($kind === 'events' ? 'events' : $base); $stamp = date('Y-m-d_His') . '_' . bin2hex(random_bytes(3)); @copy($file, $dir . '/' . $prefix . '_' . $stamp . '.json'); $limit = defined('MAX_BACKUPS') ? (int)MAX_BACKUPS : 30; $backups = glob($dir . '/' . $prefix . '_*.json') ?: []; usort($backups, static fn($a, $b) => filemtime($b) <=> filemtime($a)); while (count($backups) > $limit) @unlink(array_pop($backups)); } // --------------------------------------------------------------------------- // Transaction // --------------------------------------------------------------------------- function raijuAgentSnapshotDir(string $csId, string $phase): string { return raijuAgentDir() . '/snapshots/' . $csId . '/' . $phase; } function raijuAgentJournalPath(string $csId, string $phase): string { return raijuAgentDir() . '/journal/' . $csId . '-' . $phase . '.json'; } function raijuAgentRelative(string $file): string { $root = rtrim(realpath(raijuAgentSiteRoot()) ?: raijuAgentSiteRoot(), '/') . '/'; $real = (realpath(dirname($file)) ?: dirname($file)) . '/' . basename($file); return str_starts_with($real, $root) ? substr($real, strlen($root)) : $file; } function raijuAgentAbsolute(string $relative): string { return rtrim(realpath(raijuAgentSiteRoot()) ?: raijuAgentSiteRoot(), '/') . '/' . $relative; } function raijuAgentRawWrite(string $file, string $bytes): bool { $dir = dirname($file); if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) return false; $temporary = @tempnam($dir, '.raiju-raw-'); if ($temporary === false) return false; try { if (file_put_contents($temporary, $bytes) !== strlen($bytes)) return false; @chmod($temporary, is_file($file) ? (fileperms($file) & 0777) : (0666 & ~umask())); return rename($temporary, $file); } finally { if (is_file($temporary)) @unlink($temporary); } } /** * Write several files as one unit. The caller holds the locks. * * @param array $changes [{file, data?: array, raw?: string, remove?: bool, trash?: string}] * @param array $options testHook: callable(string $event, string $file) for failure injection (CLI only) * @throws Throwable after rolling back */ function raijuAgentWriteTransaction(string $csId, string $phase, array $changes, array $options = []): void { $hook = in_array(PHP_SAPI, ['cli', 'cli-server'], true) && is_callable($options['testHook'] ?? null) ? $options['testHook'] : null; $snapshots = raijuAgentSnapshotDir($csId, $phase); $journal = ['csId' => $csId, 'phase' => $phase, 'state' => 'applying', 'startedAt' => date('c'), 'files' => []]; foreach (array_values($changes) as $index => $change) { $file = $change['file']; $entry = ['path' => raijuAgentRelative($file), 'existed' => is_file($file), 'beforeHash' => is_file($file) ? hash_file('sha256', $file) : null, 'snapshot' => null, 'trash' => null]; if ($entry['existed']) { $snapshot = $snapshots . '/' . $index . '-' . basename($file); if (!is_dir($snapshots) && !@mkdir($snapshots, 0755, true)) throw new RuntimeException('Could not create the snapshot folder.'); if (!@copy($file, $snapshot)) throw new RuntimeException('Could not snapshot ' . basename($file) . '.'); $entry['snapshot'] = raijuAgentRelative($snapshot); } if (!empty($change['trash'])) $entry['trash'] = raijuAgentRelative($change['trash']); $journal['files'][] = $entry; } if (!raijuJsonAtomicWrite(raijuAgentJournalPath($csId, $phase), $journal)) throw new RuntimeException('Could not write the journal.'); try { foreach (array_values($changes) as $index => $change) { $file = $change['file']; if (!empty($change['remove'])) { if (!empty($change['trash'])) { if (!is_dir(dirname($change['trash'])) && !@mkdir(dirname($change['trash']), 0755, true)) throw new RuntimeException('Could not create the trash folder.'); if (!@rename($file, $change['trash'])) throw new RuntimeException('Could not move ' . basename($file) . ' to the trash.'); } elseif (!@unlink($file)) { throw new RuntimeException('Could not remove ' . basename($file) . '.'); } } elseif (isset($change['raw'])) { if (!raijuAgentRawWrite($file, (string)$change['raw'])) throw new RuntimeException('Could not write ' . basename($file) . '.'); } elseif (!raijuJsonAtomicWrite($file, (array)$change['data'])) { throw new RuntimeException('Could not write ' . basename($file) . '.'); } if ($hook) $hook('written', $file); } } catch (Throwable $error) { raijuAgentRollbackJournal($journal); @unlink(raijuAgentJournalPath($csId, $phase)); throw $error; } @unlink(raijuAgentJournalPath($csId, $phase)); } /** Put every file of a journal back to its state before the transaction. */ function raijuAgentRollbackJournal(array $journal): void { foreach (array_reverse($journal['files']) as $entry) { $file = raijuAgentAbsolute($entry['path']); if ($entry['trash'] !== null) { $trash = raijuAgentAbsolute($entry['trash']); if (!is_file($file) && is_file($trash)) @rename($trash, $file); } if ($entry['existed']) { $snapshot = $entry['snapshot'] !== null ? raijuAgentAbsolute($entry['snapshot']) : ''; if ($snapshot !== '' && is_file($snapshot) && (!is_file($file) || hash_file('sha256', $file) !== $entry['beforeHash'])) { raijuAgentRawWrite($file, (string)file_get_contents($snapshot)); } } elseif (is_file($file)) { @unlink($file); // created by the interrupted transaction } } } /** * Roll back transactions that were interrupted (crash, killed process) and mark their * change sets failed. Runs under the global lock, so nothing is in flight. * * @return string[] ids of the recovered change sets */ function raijuAgentRecoverJournalsLocked(): array { $recovered = []; foreach (glob(raijuAgentDir() . '/journal/*.json') ?: [] as $path) { $journal = raijuAgentReadJsonFile($path); if (!$journal || ($journal['state'] ?? '') !== 'applying') { @unlink($path); continue; } $locks = []; try { $locks = raijuAgentLockFiles(array_map(static fn($e) => raijuAgentAbsolute($e['path']), $journal['files'])); raijuAgentRollbackJournal($journal); } finally { raijuAgentUnlock($locks); } @unlink($path); $csId = (string)$journal['csId']; raijuAgentChangesetMutate($csId, static function (array &$cs) use ($journal) { if (in_array($cs['status'], ['applying', 'ready', 'applied'], true) && ($journal['phase'] ?? '') === 'apply') { $cs['status'] = 'failed'; $cs['error'] = 'The apply was interrupted; the files were restored.'; return true; } return false; }); raijuAgentAudit('agent-recover', true, ['cs' => $csId, 'phase' => $journal['phase'] ?? '']); $recovered[] = $csId; } return $recovered; } /** @return string[] */ function raijuAgentRecoverJournals(): array { if (!glob(raijuAgentDir() . '/journal/*.json')) return []; $lock = raijuAgentGlobalLock(0); if ($lock === false) return []; // another process is writing right now try { return raijuAgentRecoverJournalsLocked(); } finally { raijuAgentUnlock([$lock]); } } // --------------------------------------------------------------------------- // Apply // --------------------------------------------------------------------------- /** Items the user can publish afterwards, from the replay of the applied package. */ function raijuAgentPublishableItems(array $cs, array $state): array { $items = []; foreach ($state['created'] as $target => $opId) { $kind = str_starts_with($target, 'news:') ? 'news' : (str_starts_with($target, 'event:') ? 'event' : 'page'); $items[] = ['kind' => $kind, 'target' => $target, 'opId' => $opId, 'published' => false]; } foreach ($cs['ops'] as $op) { if ($op['type'] === 'section_insert') { $items[] = ['kind' => 'section', 'target' => $op['target'], 'sectionId' => $op['section']['id'], 'opId' => $op['id'], 'published' => false]; } } foreach ($state['tokens'] as $key => $tokens) { [$target, $listKey] = explode('|', $key, 2); $doc = raijuAgentFinalDoc($state, $target); if ($doc === null) continue; $listItems = raijuAgentListItemsOf($doc, $listKey); foreach ($tokens as $index => $opId) { if ($opId === null || !isset($listItems[$index])) continue; $op = null; foreach ($cs['ops'] as $candidate) if ($candidate['id'] === $opId) $op = $candidate; if ($op === null || $op['type'] !== 'list_insert' || empty($listItems[$index]['hidden'])) continue; $items[] = ['kind' => 'list_item', 'target' => $target, 'listKey' => $listKey, 'index' => $index, 'opId' => $opId, 'titleHash' => hash('sha256', raijuAgentItemLabel($listItems[$index])), 'published' => false]; } } return $items; } /** What gets written for a changed file: the replayed document plus a modification time. */ function raijuAgentFileChanges(array $changed): array { $changes = []; foreach ($changed as $file => $change) { $data = $change['after']; if (is_array($data)) $data['lastModified'] = date('c'); $changes[] = ['file' => $file, 'data' => $data]; } return $changes; } /** * Apply a reviewed change set. * * @param string $previewHash the hash the user confirmed (from the review panel) * @param array $actor {userKey, isAdmin} * @param array $options testHook (CLI only) * @return array{ok:bool,status:string,error?:string,undoToken?:string,files?:array} */ function raijuAgentChangesetApply(string $csId, string $previewHash, array $actor, array $options = []): array { $cs = raijuAgentChangesetLoad($csId); if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) return ['ok' => false, 'status' => 'missing', 'error' => 'Unknown change set.']; if ($cs['status'] !== 'ready') return ['ok' => false, 'status' => $cs['status'], 'error' => 'This change set is ' . $cs['status'] . ' and cannot be applied.']; if ($previewHash === '' || !hash_equals((string)$cs['previewHash'], $previewHash)) { return ['ok' => false, 'status' => 'ready', 'error' => 'The change set changed after the preview. Review it again before applying.']; } $global = raijuAgentGlobalLock(); if ($global === false) return ['ok' => false, 'status' => 'ready', 'error' => 'Another change is being applied. Try again in a moment.']; $locks = []; try { raijuAgentRecoverJournalsLocked(); $cs = raijuAgentChangesetLoad($csId); if ($cs === null || $cs['status'] !== 'ready') return ['ok' => false, 'status' => $cs['status'] ?? 'missing', 'error' => 'This change set is no longer ready.']; $files = []; foreach (raijuAgentOpTargets($cs['ops']) as $target) $files[] = raijuAgentTargetStorage($target)['file']; $locks = raijuAgentLockFiles($files); if ($conflicts = raijuAgentConflicts($cs)) { raijuAgentChangesetMutate($csId, static function (array &$cs) { $cs['status'] = 'conflict'; return true; }); raijuAgentAudit('agent-apply', false, ['cs' => $csId, 'reason' => 'conflict', 'targets' => $conflicts]); return ['ok' => false, 'status' => 'conflict', 'error' => 'The content changed since this change set was started (' . implode(', ', $conflicts) . '). Revalidate it first.']; } $replay = raijuAgentReplayOps($cs['ops']); $validation = raijuAgentValidate($cs, $replay, true); if (!$validation['ok']) { $blocked = array_values(array_filter(array_column($validation['errors'], 'code'), static fn($code) => is_string($code) && str_starts_with($code, 'safety_'))); if ($blocked) raijuAgentAudit('agent-safety', false, ['stage' => 'apply', 'cs' => $csId, 'codes' => $blocked]); return ['ok' => false, 'status' => 'ready', 'error' => 'Validation failed: ' . ($validation['errors'][0]['message'] ?? 'unknown error')]; } // A change set with a safety note needs one more explicit confirmation, on top of the apply click. if (($validation['risk']['level'] ?? 'ok') === 'caution' && empty($options['confirmRisk'])) { return ['ok' => false, 'status' => 'ready', 'needsRiskConfirm' => true, 'error' => raijuAgentOpText('agent.safety_confirm_needed', 'This change set has safety notes. Read them in the review and confirm them to apply it.')]; } if (($cs['renderCheck']['status'] ?? '') !== 'passed' || !hash_equals(raijuAgentPreviewHash($cs, $replay), $previewHash)) { return ['ok' => false, 'status' => 'ready', 'error' => 'The render check has not passed for the current content. Review the change set again.']; } raijuAgentChangesetMutate($csId, static function (array &$cs) { $cs['status'] = 'applying'; return true; }); $changed = raijuAgentChangedFiles($replay['state']); $changes = raijuAgentFileChanges($changed); foreach ($changed as $file => $change) { if ($change['exists']) raijuAgentBackupFile($file); } $movedMedia = []; try { // Images the package uses move into the library first; if the files cannot be written they move back. if ($cs['media']) $movedMedia = raijuAgentApplyMedia($cs, $changed); raijuAgentWriteTransaction($csId, 'apply', $changes, $options); } catch (Throwable $error) { if ($movedMedia) raijuAgentRollbackMedia($movedMedia); raijuAgentChangesetMutate($csId, static function (array &$cs) use ($error) { $cs['status'] = 'failed'; $cs['error'] = mb_substr($error->getMessage(), 0, 300, 'UTF-8'); return true; }); raijuAgentAudit('agent-apply', false, ['cs' => $csId, 'reason' => 'write-failed']); return ['ok' => false, 'status' => 'failed', 'error' => 'Applying failed and nothing was changed: ' . $error->getMessage()]; } $revisions = []; $fileInfo = []; foreach ($changed as $file => $change) { $relative = raijuAgentRelative($file); $revisions[$relative] = raijuJsonRevision($file); $fileInfo[$relative] = ['existed' => $change['exists'], 'kind' => raijuAgentTargetKindOfFile($file)]; } $applied = ['at' => date('c'), 'by' => (string)($actor['userKey'] ?? ''), 'revisions' => $revisions, 'files' => $fileInfo, 'items' => raijuAgentPublishableItems($cs, $replay['state']), 'media' => array_map(static fn($m) => ['ref' => $m['ref'], 'name' => $m['name']], $movedMedia)]; raijuAgentChangesetMutate($csId, static function (array &$cs) use ($applied) { $cs['status'] = 'applied'; $cs['applied'] = $applied; unset($cs['error']); return true; }); raijuAgentRemoveTree(raijuAgentDir() . '/staging/' . $csId); // images the package did not use are not kept raijuAgentAudit('agent-apply', true, [ 'cs' => $csId, 'run' => $cs['runId'], 'targets' => raijuAgentOpTargets($cs['ops']), 'operations' => array_map(static fn($op) => ['id' => $op['id'], 'type' => $op['type'], 'path' => $op['path'] ?? ($op['listKey'] ?? null), 'valueHash' => raijuAgentValueHash($op['value'] ?? ($op['item'] ?? ($op['post'] ?? ($op['page'] ?? ($op['event'] ?? ($op['src'] ?? null))))))], $cs['ops']), ]); return ['ok' => true, 'status' => 'applied', 'undoToken' => raijuAgentUndoToken($csId), 'files' => array_keys($revisions), 'items' => $applied['items']]; } finally { raijuAgentUnlock($locks); raijuAgentUnlock([$global]); } } // --------------------------------------------------------------------------- // Undo // --------------------------------------------------------------------------- /** * Undo an applied change set as a whole. * * Refused when a file no longer has the revision the apply left behind (someone else * changed it since). New pages go to the pages trash, new news posts are removed (a copy * stays in the snapshots), events are restored with the rest of events.json. * * @return array{ok:bool,status:string,error?:string} */ function raijuAgentChangesetUndo(string $csId, string $token, array $actor, array $options = []): array { $cs = raijuAgentChangesetLoad($csId); if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor) || !raijuAgentVerifyUndoToken($csId, $token)) { return ['ok' => false, 'status' => 'missing', 'error' => 'Undo is not possible for this change set.']; } if ($cs['status'] !== 'applied' || empty($cs['applied'])) return ['ok' => false, 'status' => $cs['status'], 'error' => 'Only an applied change set can be undone.']; $global = raijuAgentGlobalLock(); if ($global === false) return ['ok' => false, 'status' => 'applied', 'error' => 'Another change is being applied. Try again in a moment.']; $locks = []; try { raijuAgentRecoverJournalsLocked(); $cs = raijuAgentChangesetLoad($csId); $applied = $cs['applied']; $files = array_map('raijuAgentAbsolute', array_keys($applied['revisions'])); $locks = raijuAgentLockFiles($files); $changes = []; foreach ($applied['revisions'] as $relative => $revision) { $file = raijuAgentAbsolute($relative); if (raijuJsonRevision($file) !== $revision) { return ['ok' => false, 'status' => 'applied', 'error' => basename($file) . ' was changed after this change set was applied, so it cannot be undone safely.']; } $info = $applied['files'][$relative]; if ($info['existed']) { $snapshot = raijuAgentSnapshotDir($csId, 'apply'); $source = null; foreach (glob($snapshot . '/*-' . basename($file)) ?: [] as $candidate) $source = $candidate; if ($source === null) return ['ok' => false, 'status' => 'applied', 'error' => 'The snapshot of ' . basename($file) . ' is gone.']; $changes[] = ['file' => $file, 'raw' => (string)file_get_contents($source)]; } elseif ($info['kind'] === 'page') { $trashDir = defined('PAGES_TRASH_PATH') ? rtrim((string)PAGES_TRASH_PATH, '/') : raijuAgentSiteRoot() . '/content/pages-trash'; $changes[] = ['file' => $file, 'remove' => true, 'trash' => $trashDir . '/' . basename($file, '.json') . '_' . date('Y-m-d_His') . '.json']; } else { // News posts have no trash: keep a copy next to the snapshots. $keep = raijuAgentDir() . '/snapshots/' . $csId . '/undone-' . basename($file); if (!is_dir(dirname($keep)) && !@mkdir(dirname($keep), 0775, true)) return ['ok' => false, 'status' => 'applied', 'error' => 'Could not keep a copy of ' . basename($file) . '.']; if (!@copy($file, $keep)) return ['ok' => false, 'status' => 'applied', 'error' => 'Could not keep a copy of ' . basename($file) . '.']; $changes[] = ['file' => $file, 'remove' => true]; } } foreach ($changes as $change) { if (is_file($change['file'])) raijuAgentBackupFile($change['file']); } try { raijuAgentWriteTransaction($csId, 'undo', $changes, $options); } catch (Throwable $error) { raijuAgentAudit('agent-undo', false, ['cs' => $csId, 'reason' => 'write-failed']); return ['ok' => false, 'status' => 'applied', 'error' => 'Undo failed and nothing was changed: ' . $error->getMessage()]; } raijuAgentChangesetMutate($csId, static function (array &$cs) use ($actor) { $cs['status'] = 'undone'; $cs['undone'] = ['at' => date('c'), 'by' => (string)($actor['userKey'] ?? '')]; return true; }); $trashed = raijuAgentUndoMedia((array)($applied['media'] ?? [])); raijuAgentAudit('agent-undo', true, ['cs' => $csId, 'files' => array_keys($applied['revisions']), 'images' => count($trashed)]); return ['ok' => true, 'status' => 'undone']; } finally { raijuAgentUnlock($locks); raijuAgentUnlock([$global]); } } // --------------------------------------------------------------------------- // Publish // --------------------------------------------------------------------------- /** * Make one applied item visible: a news post, event, page (leaves private), list item or * section. Needs the publish right (admins have it). Updates the stored post-apply * revision, so the change set can still be undone as a whole afterwards. * * @param array $item {kind, target, listKey?, index?|sectionId?}, as listed in the apply result * @param array $actor {userKey, isAdmin, canPublish} * @return array{ok:bool,error?:string} */ function raijuAgentChangesetPublish(string $csId, array $item, array $actor, array $options = []): array { $cs = raijuAgentChangesetLoad($csId); if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) return ['ok' => false, 'error' => 'Unknown change set.']; if (empty($actor['isAdmin']) && empty($actor['canPublish'])) return ['ok' => false, 'error' => 'You are not allowed to publish.']; if ($cs['status'] !== 'applied' || empty($cs['applied'])) return ['ok' => false, 'error' => 'Only applied change sets can be published.']; $found = null; foreach ($cs['applied']['items'] as $index => $candidate) { if ($candidate['kind'] === ($item['kind'] ?? '') && $candidate['target'] === ($item['target'] ?? '') && (int)($candidate['index'] ?? -1) === (int)($item['index'] ?? ($candidate['index'] ?? -1)) && ($candidate['sectionId'] ?? '') === ($item['sectionId'] ?? ($candidate['sectionId'] ?? '')) && ($candidate['listKey'] ?? '') === ($item['listKey'] ?? ($candidate['listKey'] ?? ''))) { $found = $index; break; } } if ($found === null) return ['ok' => false, 'error' => 'This item is not part of the change set.']; $entry = $cs['applied']['items'][$found]; if (!empty($entry['published'])) return ['ok' => false, 'error' => 'This item is already published.']; $storage = raijuAgentTargetStorage($entry['target']); $global = raijuAgentGlobalLock(); if ($global === false) return ['ok' => false, 'error' => 'Another change is being applied. Try again in a moment.']; $locks = []; try { raijuAgentRecoverJournalsLocked(); $locks = raijuAgentLockFiles([$storage['file']]); $data = raijuAgentReadJsonFile($storage['file']); if ($data === null) return ['ok' => false, 'error' => 'The item no longer exists.']; $relative = raijuAgentRelative($storage['file']); $matched = raijuJsonRevision($storage['file']) === ($cs['applied']['revisions'][$relative] ?? ''); switch ($entry['kind']) { case 'news': if (empty($data['hidden'])) return ['ok' => false, 'error' => 'The post is already visible.']; $data = raijuCopilotPublishNewsData($data); break; case 'event': try { $data = raijuCopilotPublishEventData($data, $storage['key']); } catch (RuntimeException $e) { return ['ok' => false, 'error' => $e->getMessage()]; } break; case 'page': if (empty($data['visibility'])) return ['ok' => false, 'error' => 'The page is already public.']; $data = raijuCopilotPublishPageData($data); break; case 'section': $done = false; foreach ((array)($data['sections'] ?? []) as $i => $section) { if (is_array($section) && ($section['id'] ?? null) === $entry['sectionId']) { unset($data['sections'][$i]['hidden']); $done = true; } } if (!$done) return ['ok' => false, 'error' => 'The section no longer exists.']; break; case 'list_item': $items = raijuAgentListItemsOf($data, $entry['listKey']); $index = (int)$entry['index']; if (!isset($items[$index]) || empty($items[$index]['hidden']) || !hash_equals($entry['titleHash'], hash('sha256', raijuAgentItemLabel($items[$index])))) { return ['ok' => false, 'error' => 'The list changed since it was applied; publish this item in the editor.']; } unset($items[$index]['hidden']); setNestedValue($data, $entry['listKey'], array_values($items)); break; default: return ['ok' => false, 'error' => 'Unknown item.']; } $data['lastModified'] = date('c'); raijuAgentBackupFile($storage['file']); $phase = 'publish-' . ($found + 1); try { raijuAgentWriteTransaction($csId, $phase, [['file' => $storage['file'], 'data' => $data]], $options); } catch (Throwable $error) { raijuAgentAudit('agent-publish', false, ['cs' => $csId, 'item' => $entry['kind'], 'reason' => 'write-failed']); return ['ok' => false, 'error' => 'Publishing failed and nothing was changed: ' . $error->getMessage()]; } $newRevision = raijuJsonRevision($storage['file']); raijuAgentChangesetMutate($csId, static function (array &$cs) use ($found, $relative, $matched, $newRevision) { $cs['applied']['items'][$found]['published'] = true; // The publish belongs to this change set; keep Undo possible as long as nobody else touched the file. if ($matched) $cs['applied']['revisions'][$relative] = $newRevision; return true; }); raijuAgentAudit('agent-publish', true, ['cs' => $csId, 'item' => $entry['kind'], 'target' => $entry['target']]); return ['ok' => true]; } finally { raijuAgentUnlock($locks); raijuAgentUnlock([$global]); } } // --------------------------------------------------------------------------- // Cleanup // --------------------------------------------------------------------------- /** Remove old runs' leftovers: unfinished change sets and staging after 24 hours, snapshots after 30 days. */ function raijuAgentCleanup(?int $now = null): array { $now ??= time(); $removed = ['changesets' => 0, 'staging' => 0, 'snapshots' => 0]; foreach (glob(raijuAgentDir() . '/changesets/*.json') ?: [] as $file) { $cs = raijuAgentReadJsonFile($file); if (!$cs) continue; $age = $now - (int)strtotime((string)($cs['updatedAt'] ?? 'now')); $finishedOrStale = in_array($cs['status'], ['discarded', 'undone', 'failed', 'staging', 'ready', 'conflict'], true); if ($finishedOrStale && $age > RAIJU_AGENT_STAGING_HOURS * 3600 && @unlink($file)) { $removed['changesets']++; raijuAgentRemoveTree(raijuAgentDir() . '/staging/' . $cs['id']); } } foreach (glob(raijuAgentDir() . '/staging/*', GLOB_ONLYDIR) ?: [] as $dir) { if (!is_file(raijuAgentChangesetPath(basename($dir))) && $now - filemtime($dir) > RAIJU_AGENT_STAGING_HOURS * 3600) { raijuAgentRemoveTree($dir); $removed['staging']++; } } foreach (glob(raijuAgentDir() . '/snapshots/*', GLOB_ONLYDIR) ?: [] as $dir) { if ($now - filemtime($dir) > RAIJU_AGENT_SNAPSHOT_DAYS * 86400) { raijuAgentRemoveTree($dir); $removed['snapshots']++; } } if (function_exists('raijuAgentRunCleanup')) $removed['runs'] = raijuAgentRunCleanup($now); return $removed; } require_once __DIR__ . '/media.php';