Share one AI across the mesh instead of confining it to the owner
Curated state copied to every node is state that can disagree, so the index, the model and the shared memory stay on the owner and each node reaches them over the SSH trust onboarding already builds. Chats stay on the node that had them; memory and bug reports stay the owner's to write.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// The mesh entry point for the AI subsystem. Another node forwards an action here over SSH; this
|
||||
// runs it through the same vv_ai_dispatch() a browser request on this node would have used, and
|
||||
// writes the response back as JSON on stdout.
|
||||
//
|
||||
// CLI only. It is never served over HTTP and takes nothing from the environment but stdin.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// stdin {"action":"ask","params":{…},"is_post":true}
|
||||
// stdout {"_vv_rpc":1,"status":200,"body":{…}}
|
||||
//
|
||||
// The wrapper exists so an HTTP status can travel with the body — a 405 raised in the dispatcher
|
||||
// has to arrive at the calling node as a 405, not as a 200 carrying an error string.
|
||||
//
|
||||
// DESIGN PRINCIPLES
|
||||
// One implementation, two doors.
|
||||
// This shares every handler with api/ai.php through include/ai_actions.php. A mesh request
|
||||
// and a browser request cannot diverge in behaviour because there is only one behaviour.
|
||||
//
|
||||
// The caller's node id is a label, not a claim.
|
||||
// params._vv_node says which node asked, so findings and incidents are stored against the
|
||||
// node they describe. It is not consulted for authorization — possession of the partnership
|
||||
// SSH key already settled that, at onboard.
|
||||
//
|
||||
// OPERATIONAL SAFEGUARDS
|
||||
// Refuses to run anywhere but the AI owner.
|
||||
// A mirror that somehow received a forwarded request must not answer it from its own empty
|
||||
// stores. Exits non-zero so the caller reports a transport failure rather than rendering an
|
||||
// empty success.
|
||||
//
|
||||
// Refuses to run over the web.
|
||||
// Guarded on PHP_SAPI. Reachable over HTTP this would be an unauthenticated bypass of every
|
||||
// gate api/ai.php applies, since it takes its whole request from stdin.
|
||||
//
|
||||
// The master switch is honoured here too.
|
||||
// AI_ENABLED false on the owner means the mesh gets the same refusal a local request gets.
|
||||
// The calling node checks its own switch; this one checks the owner's.
|
||||
//
|
||||
// Input is size-capped before it is decoded.
|
||||
// stdin is an untrusted stream from another process. A malformed or endless payload must
|
||||
// fail as a bad request, not as an out-of-memory.
|
||||
//
|
||||
// DEPENDS ON
|
||||
// include/ai_actions.php vv_ai_dispatch() — the shared handlers
|
||||
// include/config.php vv_ai_is_owner()
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/include/ai_actions.php';
|
||||
|
||||
function vv_rpc_out(int $status, array $body): void {
|
||||
echo json_encode(['_vv_rpc' => 1, 'status' => $status, 'body' => $body],
|
||||
JSON_UNESCAPED_SLASHES), "\n";
|
||||
}
|
||||
|
||||
// Not this node's job. Exit non-zero: the caller must see a transport failure, not an answer
|
||||
// assembled from stores that are empty here by design.
|
||||
if (!vv_ai_is_owner()) {
|
||||
fwrite(STDERR, "ai_rpc: this node is not the AI owner\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// 1 MiB. A turn's history is the largest legitimate payload and is capped far below this by the
|
||||
// per-message truncation in the dispatcher; anything larger is not a request this serves.
|
||||
$raw = stream_get_contents(STDIN, 1024 * 1024);
|
||||
$req = json_decode((string)$raw, true);
|
||||
if (!is_array($req)) {
|
||||
vv_rpc_out(400, ['ok' => false, 'error' => 'ai_rpc: unreadable request']);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$action = trim((string)($req['action'] ?? ''));
|
||||
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
|
||||
$isPost = (bool)($req['is_post'] ?? false);
|
||||
|
||||
if ($action === '') {
|
||||
vv_rpc_out(400, ['ok' => false, 'error' => 'ai_rpc: no action']);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// The owner's own switch. The calling node already checked its own; this is the other half, and
|
||||
// it is what makes turning AI off here take it off the whole mesh.
|
||||
if (!vv_ai_enabled()) {
|
||||
vv_rpc_out(200, ['ok' => false, 'error' => 'AI_ENABLED is false on the AI owner — AI features are off']);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
vv_ai_log(sprintf('rpc action=%s from=%s', $action, (string)($params['_vv_node'] ?? '?')));
|
||||
|
||||
$httpStatus = 200;
|
||||
$body = vv_ai_dispatch($action, $params, $isPost, $httpStatus);
|
||||
vv_rpc_out($httpStatus, $body);
|
||||
Reference in New Issue
Block a user