Raiju/includes/ai/agent/changeset-apply.php
2026-10-25 06:05:42 +03:00

639 lines
32 KiB
PHP

<?php
/**
* Applying, undoing and publishing change sets (agent phase P3).
*
* Writes go through one transaction helper: locks (global apply lock, then every file in
* sorted order), a snapshot of each file, a journal, the writes, and a rollback from the
* snapshots when anything fails. A journal that survives a crash is rolled back by
* raijuAgentRecoverJournals(), which runs at the start of every apply/undo/publish.
*
* The usual backups (backups/) are written as well, with the names the editors use.
*/
require_once __DIR__ . '/changeset.php';
const RAIJU_AGENT_SNAPSHOT_DAYS = 30;
const RAIJU_AGENT_STAGING_HOURS = 24;
// ---------------------------------------------------------------------------
// Secret and undo token
// ---------------------------------------------------------------------------
function raijuAgentSecret(): string {
$file = raijuAgentDir() . '/.secret';
if (is_file($file)) {
$secret = trim((string)file_get_contents($file));
if (strlen($secret) >= 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';