Import verified AzerothAI snapshot from GitHub
Core tests / core (push) Successful in 9m57s
Full verification / application (push) Failing after 5m16s
Full verification / local-brain (push) Failing after 6m15s
Realm simulation / simulated-live-world (push) Failing after 5m32s
WoW integration lab / real-worldserver (push) Canceled after 17m23s
Core tests / core (push) Successful in 9m57s
Full verification / application (push) Failing after 5m16s
Full verification / local-brain (push) Failing after 6m15s
Realm simulation / simulated-live-world (push) Failing after 5m32s
WoW integration lab / real-worldserver (push) Canceled after 17m23s
Source branch: feature/ai-brain-router Source commit: c2a09e0460465201a58aaab56c71424e9a96405e
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import http from 'node:http';
|
||||
import {appendFileSync,writeFileSync} from 'node:fs';
|
||||
|
||||
const port=Number(process.env.PORT||3000);
|
||||
const token=process.env.ALE_BRIDGE_TOKEN||'ci-bridge-token';
|
||||
const file=process.env.CAPTURE_FILE||'/tmp/ale-capture.jsonl';
|
||||
let probeSent=false;
|
||||
writeFileSync(file,'');
|
||||
|
||||
function log(kind,payload){
|
||||
appendFileSync(file,JSON.stringify({at:new Date().toISOString(),kind,payload})+'\n');
|
||||
}
|
||||
|
||||
function authorized(req){
|
||||
return req.headers.authorization===`Bearer ${token}`;
|
||||
}
|
||||
|
||||
function read(req){
|
||||
return new Promise((resolve,reject)=>{
|
||||
let body='';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data',c=>{body+=c;if(body.length>100000){reject(new Error('too large'));req.destroy();}});
|
||||
req.on('end',()=>resolve(body));
|
||||
req.on('error',reject);
|
||||
});
|
||||
}
|
||||
|
||||
const probe=`local __command_id="ci-probe"
|
||||
local function __stringify(v)
|
||||
if v==nil then return "nil" end
|
||||
return tostring(v)
|
||||
end
|
||||
local __ok,__result=pcall(function()
|
||||
local core=tostring(GetCoreName())
|
||||
local version=tostring(GetCoreVersion())
|
||||
local lua=tostring(GetLuaEngine())
|
||||
local players=tostring(GetPlayerCount())
|
||||
local pb="unknown"
|
||||
if type(GetRandomPlayerbotMgr)=="function" then
|
||||
local ok,mgr=pcall(GetRandomPlayerbotMgr)
|
||||
pb=ok and (mgr and "available" or "nil") or "error"
|
||||
end
|
||||
return "core="..core.."|version="..version.."|lua="..lua.."|players="..players.."|playerbots="..pb
|
||||
end)
|
||||
AzerothAIComplete(__command_id,__ok,__stringify(__result))
|
||||
`;
|
||||
|
||||
const server=http.createServer(async(req,res)=>{
|
||||
try{
|
||||
if(req.url==='/health'){
|
||||
res.writeHead(200,{'content-type':'application/json'});
|
||||
return res.end(JSON.stringify({ok:true,probeSent}));
|
||||
}
|
||||
if(!authorized(req)){
|
||||
res.writeHead(401);
|
||||
return res.end('unauthorized');
|
||||
}
|
||||
if(req.method==='GET'&&req.url==='/api/bridge/ale/poll'){
|
||||
log('poll',{probeSent});
|
||||
if(!probeSent){
|
||||
probeSent=true;
|
||||
res.writeHead(200,{'content-type':'text/plain; charset=utf-8','x-azerothai-command':'ci-probe'});
|
||||
return res.end(probe);
|
||||
}
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
if(req.method==='POST'&&req.url==='/api/bridge/ale/event'){
|
||||
const body=await read(req);
|
||||
let parsed;try{parsed=JSON.parse(body);}catch{parsed={raw:body};}
|
||||
log('event',parsed);
|
||||
res.writeHead(200,{'content-type':'application/json'});
|
||||
return res.end('{"ok":true}');
|
||||
}
|
||||
if(req.method==='POST'&&req.url==='/api/bridge/ale/result'){
|
||||
const body=await read(req);
|
||||
let parsed;try{parsed=JSON.parse(body);}catch{parsed={raw:body};}
|
||||
log('result',parsed);
|
||||
res.writeHead(200,{'content-type':'application/json'});
|
||||
return res.end('{"ok":true}');
|
||||
}
|
||||
res.writeHead(404);res.end('not found');
|
||||
}catch(e){
|
||||
log('server-error',{message:e instanceof Error?e.message:String(e)});
|
||||
res.writeHead(500);res.end('error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port,'0.0.0.0',()=>console.log(`ALE capture server listening on ${port}`));
|
||||
@@ -0,0 +1,165 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
|
||||
const BASE=(process.env.AZEROTHAI_URL||'http://127.0.0.1:8787').replace(/\/+$/,'');
|
||||
const TOKEN=process.env.ALE_BRIDGE_TOKEN||'';
|
||||
const DB_PASSWORD=process.env.DOCKER_DB_ROOT_PASSWORD||'';
|
||||
const DB_CONTAINER=process.env.AZEROTHAI_DB_CONTAINER||'ac-database';
|
||||
const POLL_MS=Math.max(100,Number(process.env.AZEROTHAI_ALE_RELAY_POLL_MS||250));
|
||||
const EVENT_BATCH=Math.max(1,Math.min(20,Number(process.env.AZEROTHAI_ALE_EVENT_BATCH||8)));
|
||||
const EVENT_GAP_MS=Math.max(0,Number(process.env.AZEROTHAI_ALE_EVENT_GAP_MS||75));
|
||||
|
||||
if(!TOKEN)throw new Error('ALE_BRIDGE_TOKEN is required');
|
||||
if(!DB_PASSWORD)throw new Error('DOCKER_DB_ROOT_PASSWORD is required');
|
||||
|
||||
let stopping=false;
|
||||
let lastCleanup=0;
|
||||
process.on('SIGINT',()=>{stopping=true;});
|
||||
process.on('SIGTERM',()=>{stopping=true;});
|
||||
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
|
||||
function sqlText(value){
|
||||
const hex=Buffer.from(String(value??''),'utf8').toString('hex');
|
||||
return `CONVERT(UNHEX('${hex}') USING utf8mb4)`;
|
||||
}
|
||||
function mysql(sql){
|
||||
return execFileSync('docker',[
|
||||
'exec','-e',`MYSQL_PWD=${DB_PASSWORD}`,DB_CONTAINER,
|
||||
'mysql','-uroot','-N','-B','-e',sql
|
||||
],{encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||
}
|
||||
|
||||
function ensureSchema(){
|
||||
mysql(`
|
||||
CREATE TABLE IF NOT EXISTS acore_auth.azerothai_command_inbox (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
source MEDIUMTEXT NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
consumed_at TIMESTAMP(6) NULL,
|
||||
KEY idx_azerothai_command_inbox_pending (consumed_at,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS acore_auth.azerothai_event_outbox (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(100) NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
delivered_at TIMESTAMP(6) NULL,
|
||||
KEY idx_azerothai_event_outbox_pending (delivered_at,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS acore_auth.azerothai_result_outbox (
|
||||
seq BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
command_id VARCHAR(36) NOT NULL,
|
||||
ok TINYINT(1) NOT NULL,
|
||||
result MEDIUMTEXT NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
delivered_at TIMESTAMP(6) NULL,
|
||||
UNIQUE KEY uq_azerothai_result_command (command_id),
|
||||
KEY idx_azerothai_result_outbox_pending (delivered_at,seq)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`);
|
||||
}
|
||||
|
||||
async function backend(path,init={}){
|
||||
const headers=new Headers(init.headers||{});
|
||||
headers.set('Authorization',`Bearer ${TOKEN}`);
|
||||
headers.set('Accept','application/json');
|
||||
const controller=new AbortController();
|
||||
const timer=setTimeout(()=>controller.abort(),5000);
|
||||
try{
|
||||
return await fetch(BASE+path,{...init,headers,signal:controller.signal});
|
||||
}finally{
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function pullCommand(){
|
||||
const response=await backend('/api/bridge/ale/poll',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:'{}'
|
||||
});
|
||||
if(!response.ok)throw new Error(`poll HTTP ${response.status}: ${await response.text()}`);
|
||||
const id=response.headers.get('x-azerothai-command');
|
||||
const source=await response.text();
|
||||
if(!id)return false;
|
||||
mysql(`INSERT INTO acore_auth.azerothai_command_inbox(id,source)
|
||||
VALUES (${sqlText(id)},${sqlText(source)})
|
||||
ON DUPLICATE KEY UPDATE source=VALUES(source)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function rows(sql){
|
||||
const out=mysql(sql).trim();
|
||||
if(!out)return [];
|
||||
return out.split('\n').filter(Boolean).map(line=>line.split('\t'));
|
||||
}
|
||||
|
||||
async function deliverEvents(){
|
||||
const batch=rows(`SELECT id,HEX(kind),HEX(payload)
|
||||
FROM acore_auth.azerothai_event_outbox
|
||||
WHERE delivered_at IS NULL
|
||||
ORDER BY CASE WHEN kind IN ('player.whisper','player.chat') THEN 0 ELSE 1 END, id
|
||||
LIMIT ${EVENT_BATCH}`);
|
||||
for(const [id,kindHex,payloadHex] of batch){
|
||||
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
|
||||
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
|
||||
let payload;
|
||||
try{payload=JSON.parse(payloadText);}catch{
|
||||
console.error(`Dropping malformed ALE event ${id}: invalid JSON payload`);
|
||||
mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id=${Number(id)}`);
|
||||
continue;
|
||||
}
|
||||
const response=await backend('/api/bridge/ale/event',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({kind,payload})
|
||||
});
|
||||
if(!response.ok)throw new Error(`event HTTP ${response.status}: ${await response.text()}`);
|
||||
mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id=${Number(id)}`);
|
||||
if(EVENT_GAP_MS)await sleep(EVENT_GAP_MS);
|
||||
}
|
||||
return batch.length;
|
||||
}
|
||||
|
||||
async function deliverResults(){
|
||||
const batch=rows("SELECT seq,command_id,ok,HEX(result) FROM acore_auth.azerothai_result_outbox WHERE delivered_at IS NULL ORDER BY seq LIMIT 20");
|
||||
for(const [seq,commandId,ok,resultHex] of batch){
|
||||
const result=Buffer.from(resultHex||'','hex').toString('utf8').slice(0,16000);
|
||||
const response=await backend('/api/bridge/ale/result',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id:commandId,ok:ok==='1',result})
|
||||
});
|
||||
if(!response.ok)throw new Error(`result HTTP ${response.status}: ${await response.text()}`);
|
||||
mysql(`UPDATE acore_auth.azerothai_result_outbox SET delivered_at=NOW(6) WHERE seq=${Number(seq)}`);
|
||||
}
|
||||
return batch.length;
|
||||
}
|
||||
|
||||
function cleanup(){
|
||||
mysql(`
|
||||
UPDATE acore_auth.azerothai_event_outbox
|
||||
SET delivered_at=NOW(6)
|
||||
WHERE delivered_at IS NULL
|
||||
AND kind IN ('player.combat.enter','player.combat.leave')
|
||||
AND created_at < NOW() - INTERVAL 2 MINUTE;
|
||||
DELETE FROM acore_auth.azerothai_command_inbox WHERE consumed_at IS NOT NULL AND consumed_at < NOW() - INTERVAL 1 DAY;
|
||||
DELETE FROM acore_auth.azerothai_event_outbox WHERE delivered_at IS NOT NULL AND delivered_at < NOW() - INTERVAL 1 DAY;
|
||||
DELETE FROM acore_auth.azerothai_result_outbox WHERE delivered_at IS NOT NULL AND delivered_at < NOW() - INTERVAL 1 DAY;`);
|
||||
}
|
||||
|
||||
ensureSchema();
|
||||
console.log(`AzerothAI ALE DB relay started: ${BASE}, poll ${POLL_MS}ms`);
|
||||
|
||||
while(!stopping){
|
||||
try{
|
||||
await deliverEvents();
|
||||
await deliverResults();
|
||||
await pullCommand();
|
||||
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
|
||||
await sleep(POLL_MS);
|
||||
}catch(error){
|
||||
console.error(new Date().toISOString(),error instanceof Error?error.message:String(error));
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('AzerothAI ALE DB relay stopped.');
|
||||
@@ -0,0 +1,52 @@
|
||||
const base=(process.env.AZEROTHAI_URL||'http://127.0.0.1:8787').replace(/\/$/,'');
|
||||
const token=process.env.AUTONOMY_TOKEN;
|
||||
const autonomyInterval=Math.max(10_000,Number(process.env.AUTONOMY_POLL_MS||30_000));
|
||||
const conversationInterval=Math.max(500,Number(process.env.CONVERSATION_POLL_MS||1000));
|
||||
|
||||
if(!token){
|
||||
console.error('AUTONOMY_TOKEN is required');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let stopping=false;
|
||||
for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>{stopping=true;});
|
||||
|
||||
async function post(path,timeout=60_000){
|
||||
const response=await fetch(`${base}${path}`,{
|
||||
method:'POST',
|
||||
headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},
|
||||
body:'{}',
|
||||
signal:AbortSignal.timeout(timeout)
|
||||
});
|
||||
const raw=await response.text();
|
||||
if(!response.ok)throw new Error(`${path} HTTP ${response.status}: ${raw.slice(0,500)}`);
|
||||
try{return JSON.parse(raw);}catch{return {raw};}
|
||||
}
|
||||
|
||||
async function autonomyTick(){
|
||||
const body=await post('/api/autonomy/tick');
|
||||
const stamp=new Date().toISOString();
|
||||
if(body.ran)console.log(`[${stamp}] ${body.name}: ${body.decision?.action} -> command ${body.commandId}`);
|
||||
else console.log(`[${stamp}] idle: ${body.reason||'no due resident'}`);
|
||||
}
|
||||
|
||||
async function conversationTick(){
|
||||
const body=await post('/api/conversation/tick');
|
||||
if(body.ran){
|
||||
console.log(`[${new Date().toISOString()}] chat: ${body.name} -> ${body.speaker}: ${body.reply} (command ${body.commandId})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`AzerothAI autonomy worker starting: ${base}, autonomy ${autonomyInterval}ms, conversation ${conversationInterval}ms`);
|
||||
let nextAutonomy=0;
|
||||
while(!stopping){
|
||||
try{await conversationTick();}catch(e){console.error(`[${new Date().toISOString()}] ${e instanceof Error?e.message:String(e)}`);}
|
||||
if(stopping)break;
|
||||
if(Date.now()>=nextAutonomy){
|
||||
try{await autonomyTick();}catch(e){console.error(`[${new Date().toISOString()}] ${e instanceof Error?e.message:String(e)}`);}
|
||||
nextAutonomy=Date.now()+autonomyInterval;
|
||||
}
|
||||
if(stopping)break;
|
||||
await new Promise(resolve=>setTimeout(resolve,conversationInterval));
|
||||
}
|
||||
console.log('AzerothAI autonomy worker stopped.');
|
||||
@@ -0,0 +1,35 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
|
||||
const base=(process.env.OLLAMA_URL||'http://127.0.0.1:11434').replace(/\/$/,'');
|
||||
const models=(process.env.MODELS||'qwen3.5:4b').split(',').map(x=>x.trim()).filter(Boolean);
|
||||
const scenarios=JSON.parse(await readFile(new URL('../benchmarks/brain-scenarios.json',import.meta.url),'utf8'));
|
||||
|
||||
function prompt(s){
|
||||
return [
|
||||
{role:'system',content:'You choose one high-level intention for a person living in Azeroth. Stay in character. Return only JSON {"action":"explore|quest|socialize|trade|rest","reason":"brief in-character rationale","goal":"short goal"}. Use only an allowed action.'},
|
||||
{role:'user',content:JSON.stringify(s)}
|
||||
];
|
||||
}
|
||||
|
||||
const rows=[];
|
||||
for(const model of models){
|
||||
for(const scenario of scenarios){
|
||||
const started=performance.now();
|
||||
let valid=false,allowed=false,action='',reason='',error='';
|
||||
try{
|
||||
const response=await fetch(base+'/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model,messages:prompt(scenario),stream:false,format:'json',options:{temperature:0.7,num_predict:260}})});
|
||||
if(!response.ok)throw new Error('HTTP '+response.status);
|
||||
const data=await response.json();
|
||||
const parsed=JSON.parse(data.message?.content||'{}');
|
||||
valid=typeof parsed.action==='string'&&typeof parsed.reason==='string'&&typeof parsed.goal==='string';
|
||||
action=parsed.action||'';
|
||||
reason=parsed.reason||'';
|
||||
allowed=scenario.allowed.includes(action);
|
||||
}catch(e){error=e instanceof Error?e.message:String(e);}
|
||||
rows.push({model,scenario:scenario.id,latencyMs:Math.round(performance.now()-started),validJson:valid,allowedAction:allowed,action,reason:reason.slice(0,160),error});
|
||||
}
|
||||
}
|
||||
console.table(rows.map(({reason,...r})=>r));
|
||||
for(const row of rows){
|
||||
console.log('\n['+row.model+' / '+row.scenario+'] '+(row.error?'ERROR '+row.error:row.action+' — '+row.reason));
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [[ "${SITES_ENV_READY:-}" != "1" ]]; then
|
||||
exec "${script_dir}/sites-env.sh" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
command -v timeout || {
|
||||
echo "build-verified.sh requires GNU timeout." >&2
|
||||
exit 69
|
||||
}
|
||||
|
||||
vinext="${SITES_PROJECT_ROOT}/node_modules/.bin/vinext"
|
||||
if [[ ! -x "${vinext}" ]]; then
|
||||
echo "vinext is unavailable. Run npm run install:ci and wait for it to finish before building." >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
echo "Running bounded vinext build..."
|
||||
timeout \
|
||||
--signal=TERM \
|
||||
--kill-after="${SITES_BUILD_KILL_AFTER:-10s}" \
|
||||
"${SITES_BUILD_TIMEOUT:-3m}" \
|
||||
"${vinext}" build
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export function readExecutionProfile() {
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(new URL("../.sites-runtime/execution-profile.json", import.meta.url), "utf8"));
|
||||
} catch (error) {
|
||||
// Clean clones and remote builds have no checkout-local selection.
|
||||
if (error.code === "ENOENT") return "portable";
|
||||
throw error;
|
||||
}
|
||||
if (!["managed-linux", "portable"].includes(settings?.executionProfile)) {
|
||||
throw new Error("Invalid local execution profile; rerun the Sites plugin's configure-execution-profile.mjs.");
|
||||
}
|
||||
return settings.executionProfile;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { projectRoot } from "./sites-env.mjs";
|
||||
import { readExecutionProfile } from "./execution-profile.mjs";
|
||||
|
||||
if (!process.env.npm_execpath) {
|
||||
throw new Error("Run this installer with npm run install:ci.");
|
||||
}
|
||||
|
||||
if (![
|
||||
"SHARP_IGNORE_GLOBAL_LIBVIPS",
|
||||
"SHARP_FORCE_GLOBAL_LIBVIPS",
|
||||
"npm_config_build_from_source",
|
||||
"NPM_CONFIG_BUILD_FROM_SOURCE",
|
||||
].some((key) => key in process.env)) {
|
||||
process.env.SHARP_IGNORE_GLOBAL_LIBVIPS = "1";
|
||||
}
|
||||
|
||||
if (readExecutionProfile() === "managed-linux") {
|
||||
const result = spawnSync("bash", [path.join(projectRoot, "scripts/install-ci.sh")], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Invoke npm's JavaScript entrypoint, avoiding platform-specific shell shims.
|
||||
const installed = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
process.env.npm_execpath, "ci", "--prefix", projectRoot, "--workspaces=false",
|
||||
"--include=dev", "--include=optional", "--prefer-offline", "--no-audit", "--no-fund",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (installed.error) throw installed.error;
|
||||
if (installed.status !== 0) process.exit(installed.status ?? 1);
|
||||
|
||||
try {
|
||||
accessSync(
|
||||
path.join(
|
||||
projectRoot, "node_modules", ".bin",
|
||||
process.platform === "win32" ? "vinext.cmd" : "vinext",
|
||||
),
|
||||
process.platform === "win32" ? constants.F_OK : constants.X_OK,
|
||||
);
|
||||
} catch {
|
||||
console.error("npm ci exited successfully but the local vinext executable is unavailable.");
|
||||
process.exitCode = 69;
|
||||
}
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [[ "${SITES_ENV_READY:-}" != "1" ]]; then
|
||||
exec "${script_dir}/sites-env.sh" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
command -v flock || {
|
||||
echo "install-ci.sh requires Linux flock." >&2
|
||||
exit 69
|
||||
}
|
||||
command -v timeout || {
|
||||
echo "install-ci.sh requires GNU timeout." >&2
|
||||
exit 69
|
||||
}
|
||||
command -v curl || {
|
||||
echo "install-ci.sh requires curl for the locked-tarball preflight." >&2
|
||||
exit 69
|
||||
}
|
||||
command -v sha256sum || {
|
||||
echo "install-ci.sh requires sha256sum for cache and install verification." >&2
|
||||
exit 69
|
||||
}
|
||||
|
||||
runtime_root="${SITES_RUNTIME_ROOT:-${SITES_PROJECT_ROOT}/.sites-runtime}"
|
||||
expected_home="${runtime_root}/home"
|
||||
expected_cache="${runtime_root}/npm-cache"
|
||||
|
||||
echo "[sites] validating writable install environment"
|
||||
if [[ "${HOME}" != "${expected_home}" ]]; then
|
||||
echo "Expected HOME=${expected_home}, got HOME=${HOME}." >&2
|
||||
exit 78
|
||||
fi
|
||||
actual_cache="$(npm --prefix "${SITES_PROJECT_ROOT}" --workspaces=false config get cache)"
|
||||
if [[ "${actual_cache}" != "${expected_cache}" ]]; then
|
||||
echo "Expected npm cache ${expected_cache}, got ${actual_cache}." >&2
|
||||
exit 78
|
||||
fi
|
||||
touch "${HOME}/.sites-write-test" "${expected_cache}/.sites-write-test"
|
||||
rm -f "${HOME}/.sites-write-test" "${expected_cache}/.sites-write-test"
|
||||
echo "[sites] environment passed: HOME=${HOME}, cache=${expected_cache}"
|
||||
|
||||
lock_file="${runtime_root}/install.lock"
|
||||
exec 9>"${lock_file}"
|
||||
if ! flock -n 9; then
|
||||
echo "Another dependency install is already running for ${SITES_PROJECT_ROOT}." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
# Catch an installer started outside this helper. Linux exposes both its command
|
||||
# line and working directory through /proc, so avoid broad process-name matches.
|
||||
for process in /proc/[0-9]*; do
|
||||
pid="${process##*/}"
|
||||
[[ "${pid}" != "$$" && "${pid}" != "${PPID}" ]] || continue
|
||||
process_cwd="$(readlink -f "${process}/cwd" || true)"
|
||||
[[ "${process_cwd}" == "${SITES_PROJECT_ROOT}" ]] || continue
|
||||
process_command="$(tr '\0' ' ' <"${process}/cmdline" || true)"
|
||||
if [[ "${process_command}" == *"npm ci"* ]]; then
|
||||
echo "Another npm ci is visible in ${SITES_PROJECT_ROOT}; refusing to overlap installs." >&2
|
||||
exit 75
|
||||
fi
|
||||
done
|
||||
|
||||
lockfile_sha256="$(sha256sum "${SITES_PROJECT_ROOT}/package-lock.json" | awk '{print $1}')"
|
||||
use_seeded_cache=0
|
||||
# Report seed selection separately from package cache hits or downloads.
|
||||
cache_seed_result=seed_unavailable
|
||||
seed_cache="${SITES_NPM_CACHE_SEED:-}"
|
||||
if [[ -n "${seed_cache}" && -d "${seed_cache}" ]]; then
|
||||
seed_lockfile_sha256="$(cat "${seed_cache}/.sites-lockfile-sha256" || true)"
|
||||
if [[ "${seed_lockfile_sha256}" == "${lockfile_sha256}" ]]; then
|
||||
echo "[sites] restoring image-seeded npm cache"
|
||||
cp -a "${seed_cache}/." "${expected_cache}/"
|
||||
use_seeded_cache=1
|
||||
cache_seed_result=seed_used
|
||||
echo "[sites] image cache seed matched; registry fallback remains available"
|
||||
else
|
||||
cache_seed_result=seed_lockfile_mismatch
|
||||
echo "[sites] image cache seed does not match this lockfile; using the network path"
|
||||
fi
|
||||
fi
|
||||
|
||||
locked_vinext_output="$({ node --input-type=module - "${SITES_PROJECT_ROOT}/package-lock.json" <<'NODE'
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const lock = JSON.parse(await readFile(process.argv[2], "utf8"));
|
||||
const vinext = lock.packages?.["node_modules/vinext"];
|
||||
if (!vinext?.resolved || !vinext?.integrity) {
|
||||
throw new Error("package-lock.json does not contain a resolved, integrity-pinned vinext tarball");
|
||||
}
|
||||
console.log(vinext.resolved);
|
||||
console.log(vinext.integrity);
|
||||
NODE
|
||||
})" || {
|
||||
echo "Could not read the integrity-pinned vinext tarball from package-lock.json." >&2
|
||||
exit 65
|
||||
}
|
||||
mapfile -t locked_vinext <<<"${locked_vinext_output}"
|
||||
if [[ "${#locked_vinext[@]}" -ne 2 ]]; then
|
||||
echo "Expected exactly one Vinext URL and integrity value from package-lock.json." >&2
|
||||
exit 65
|
||||
fi
|
||||
|
||||
locked_tarball="${locked_vinext[0]}"
|
||||
locked_integrity="${locked_vinext[1]}"
|
||||
|
||||
if [[ "${use_seeded_cache}" == "0" ]]; then
|
||||
registry="$(npm --prefix "${SITES_PROJECT_ROOT}" --workspaces=false config get registry)"
|
||||
preflight_url="$({ node --input-type=module - "${locked_tarball}" "${registry}" <<'NODE'
|
||||
const locked = new URL(process.argv[2]);
|
||||
const registry = new URL(process.argv[3]);
|
||||
if (locked.hostname === "registry.npmjs.org") {
|
||||
locked.protocol = registry.protocol;
|
||||
locked.host = registry.host;
|
||||
locked.pathname = `${registry.pathname.replace(/\/$/, "")}${locked.pathname}`;
|
||||
}
|
||||
process.stdout.write(locked.href);
|
||||
NODE
|
||||
})" || {
|
||||
echo "Could not construct the locked-tarball preflight URL." >&2
|
||||
exit 65
|
||||
}
|
||||
|
||||
preflight_dir="${runtime_root}/preflight"
|
||||
preflight_tarball="${preflight_dir}/vinext.tgz"
|
||||
mkdir -p "${preflight_dir}"
|
||||
|
||||
echo "[sites] downloading the complete locked vinext tarball"
|
||||
curl \
|
||||
--fail \
|
||||
--location \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 0 \
|
||||
--connect-timeout 15 \
|
||||
--max-time 120 \
|
||||
--output "${preflight_tarball}" \
|
||||
"${preflight_url}"
|
||||
|
||||
echo "[sites] verifying locked vinext tarball integrity"
|
||||
node --input-type=module - "${preflight_tarball}" "${locked_integrity}" <<'NODE'
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const [algorithm, expected] = process.argv[3].split("-", 2);
|
||||
if (!algorithm || !expected) {
|
||||
throw new Error(`unsupported integrity value: ${process.argv[3]}`);
|
||||
}
|
||||
const actual = createHash(algorithm)
|
||||
.update(await readFile(process.argv[2]))
|
||||
.digest("base64");
|
||||
if (actual !== expected) {
|
||||
throw new Error(`vinext tarball integrity mismatch for ${algorithm}`);
|
||||
}
|
||||
NODE
|
||||
echo "[sites] network and integrity preflight passed"
|
||||
fi
|
||||
|
||||
echo "[sites] running exactly one bounded npm ci"
|
||||
export NPM_CONFIG_MAXSOCKETS=1
|
||||
export NPM_CONFIG_FETCH_RETRIES=0
|
||||
export NPM_CONFIG_FETCH_TIMEOUT=30000
|
||||
npm_ci_args=(ci --prefix "${SITES_PROJECT_ROOT}" --workspaces=false --include=dev --include=optional --cache "${expected_cache}")
|
||||
if [[ "${use_seeded_cache}" == "1" ]]; then
|
||||
npm_ci_args+=(--prefer-offline)
|
||||
fi
|
||||
timeout \
|
||||
--signal=TERM \
|
||||
--kill-after="${SITES_INSTALL_KILL_AFTER:-15s}" \
|
||||
"${SITES_INSTALL_TIMEOUT:-8m}" \
|
||||
npm "${npm_ci_args[@]}"
|
||||
|
||||
vinext="${SITES_PROJECT_ROOT}/node_modules/.bin/vinext"
|
||||
if [[ ! -x "${vinext}" ]]; then
|
||||
echo "npm ci exited successfully but node_modules/.bin/vinext is unavailable." >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
node --input-type=module - "${SITES_PROJECT_ROOT}/node_modules/.sites-install.json" "${lockfile_sha256}" "${cache_seed_result}" <<'NODE'
|
||||
import { constants } from "node:fs";
|
||||
import { open, writeFile } from "node:fs/promises";
|
||||
|
||||
await writeFile(
|
||||
process.argv[2],
|
||||
`${JSON.stringify({
|
||||
lockfile_sha256: process.argv[3],
|
||||
node: process.version,
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
// The measured plugin wrapper supplies an existing private file for this attempt.
|
||||
// Cache telemetry must not change the install result or create arbitrary files.
|
||||
const reportPath = process.env.SITES_INSTALL_REPORT_PATH;
|
||||
if (reportPath) {
|
||||
let report;
|
||||
try {
|
||||
report = await open(reportPath, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
if ((await report.stat()).isFile()) {
|
||||
await report.truncate(0);
|
||||
await report.writeFile(`${JSON.stringify({ version: 1, cache_seed: process.argv[4] })}\n`);
|
||||
}
|
||||
} catch {
|
||||
// Leave the decision unavailable rather than inventing seed use or failing setup.
|
||||
} finally {
|
||||
await report?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
NODE
|
||||
echo "[sites] npm ci passed and vinext is available"
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ "${SITES_ENV_READY:-}" != "1" ]]; then
|
||||
exec "${script_dir}/sites-env.sh" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
require_shared=0
|
||||
prepare_only=0
|
||||
for argument in "$@"; do
|
||||
case "${argument}" in
|
||||
--require-shared) require_shared=1 ;;
|
||||
--prepare-store) prepare_only=1 ;;
|
||||
*) echo "Unknown pnpm installation option: ${argument}" >&2; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for tool in flock timeout node; do
|
||||
command -v "${tool}" >/dev/null || { echo "pnpm setup requires Linux flock, GNU timeout, and Node.js." >&2; exit 69; }
|
||||
done
|
||||
cache_seed=decision_unavailable
|
||||
store_scope=unknown
|
||||
store_state=unavailable
|
||||
report_store() {
|
||||
node "${script_dir}/pnpm-install.mjs" --report-store "${cache_seed}" "${store_scope}" "${store_state}" "${prepare_only}"
|
||||
}
|
||||
# Publish preparation failures too; the optional report never decides success.
|
||||
trap 'report_store || true' EXIT
|
||||
|
||||
if [[ -n "${SITES_PNPM_BIN:-}" && -f "${SITES_PNPM_BIN}" && -r "${SITES_PNPM_BIN}" ]]; then
|
||||
pnpm_command=(node "${SITES_PNPM_BIN}")
|
||||
elif [[ "${require_shared}" == 1 ]]; then
|
||||
echo "[sites] the image-pinned pnpm is unavailable" >&2
|
||||
exit 69
|
||||
elif command -v corepack >/dev/null; then
|
||||
# Corepack honors the established project's pin even when PATH has a different
|
||||
# pnpm version. A bare pnpm is used only if Corepack is absent, then verified.
|
||||
pnpm_command=(corepack pnpm)
|
||||
elif command -v pnpm >/dev/null; then
|
||||
pnpm_command=(pnpm)
|
||||
else
|
||||
echo "This pnpm project requires pnpm 11.25.0 or Corepack." >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
runtime_root="${SITES_RUNTIME_ROOT:-${SITES_PROJECT_ROOT}/.sites-runtime}"
|
||||
private_store="${runtime_root}/pnpm-store"
|
||||
if [[ "${HOME}" != "${runtime_root}/home" || -L "${private_store}" ]]; then
|
||||
echo "Dependency setup requires a project-owned writable home and pnpm store." >&2
|
||||
exit 78
|
||||
fi
|
||||
# A single Node process owns the regular-file descriptors for both leases.
|
||||
# Closing its input (including this shell exiting) releases the project lock.
|
||||
coproc SITES_INSTALL_LOCKS {
|
||||
node "${script_dir}/pnpm-install.mjs" --hold-install-locks \
|
||||
"${runtime_root}/install.lock" "${SITES_PNPM_SHARED_STORE:-}.seed.lock" \
|
||||
"${SITES_PNPM_STORE_LOCK_TIMEOUT:-30}"
|
||||
}
|
||||
install_lock_pid="${SITES_INSTALL_LOCKS_PID}"
|
||||
install_lock_output="${SITES_INSTALL_LOCKS[0]}"
|
||||
install_lock_input="${SITES_INSTALL_LOCKS[1]}"
|
||||
lock_status=unavailable
|
||||
read -r lock_status <&"${install_lock_output}" || true
|
||||
if [[ "${lock_status}" != locked ]]; then
|
||||
exec {install_lock_input}>&-
|
||||
exec {install_lock_output}<&-
|
||||
wait "${install_lock_pid}" || true
|
||||
if [[ "${lock_status}" == busy ]]; then
|
||||
echo "Another dependency install is already running for ${SITES_PROJECT_ROOT}." >&2
|
||||
exit 75
|
||||
fi
|
||||
echo "Dependency setup requires a regular, writable project install lock." >&2
|
||||
exit 78
|
||||
fi
|
||||
export XDG_CACHE_HOME="${runtime_root}/xdg-cache" XDG_DATA_HOME="${runtime_root}/xdg-data"
|
||||
mkdir -p "${XDG_CACHE_HOME}" "${XDG_DATA_HOME}" || exit 70
|
||||
pnpm_version="$(timeout --signal=TERM --kill-after="${SITES_INSTALL_KILL_AFTER:-15s}" \
|
||||
"${SITES_PNPM_BOOTSTRAP_TIMEOUT:-30s}" "${pnpm_command[@]}" --version)"
|
||||
if [[ "${pnpm_version}" != "11.25.0" ]]; then
|
||||
echo "This project requires pnpm 11.25.0 and its v11 store format." >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
can_write_directory() {
|
||||
local probe
|
||||
# access()/test -w can succeed even when a kernel sandbox denies a write.
|
||||
probe="$(mktemp "${1}/.sites-store-probe.XXXXXX" 2>/dev/null)" || return 1
|
||||
rm -f -- "${probe}" 2>/dev/null
|
||||
}
|
||||
|
||||
release_shared_lock() {
|
||||
if [[ "${shared_lock_held:-}" == 1 ]]; then
|
||||
printf '%s\n' release-shared >&"${install_lock_input}"
|
||||
local status
|
||||
read -r status <&"${install_lock_output}" && [[ "${status}" == released ]] || return 70
|
||||
shared_lock_held=0
|
||||
fi
|
||||
}
|
||||
|
||||
acquire_shared_lock() {
|
||||
# The Node holder opens this workspace-controlled entry with no-follow and
|
||||
# nonblocking flags, then verifies a regular file before bounded flock.
|
||||
printf '%s\n' shared >&"${install_lock_input}"
|
||||
local status
|
||||
if read -r status <&"${install_lock_output}" && [[ "${status}" == locked ]]; then
|
||||
shared_lock_held=1
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
writable_store="${private_store}"
|
||||
shared_store="${SITES_PNPM_SHARED_STORE:-}"
|
||||
if [[ "${shared_store}" == "/workspace/.sites-runtime/pnpm-store" &&
|
||||
! -L "${shared_store}" && ! -L "${shared_store%/*}" &&
|
||||
-d "${shared_store%/*/*}" ]]; then
|
||||
# Normal Work already writes this owner's workspace. Narrower profiles must
|
||||
# pass the actual create probe, without widening their permissions.
|
||||
if mkdir -p "${shared_store%/*}" 2>/dev/null &&
|
||||
can_write_directory "${shared_store%/*}" &&
|
||||
acquire_shared_lock; then
|
||||
if [[ ! -L "${shared_store}" && ! -L "${shared_store%/*}" &&
|
||||
( ! -e "${shared_store}" || ( -d "${shared_store}" && ! -L "${shared_store}" && -w "${shared_store}" ) ) ]] &&
|
||||
{ [[ ! -d "${shared_store}" ]] || can_write_directory "${shared_store}"; }; then
|
||||
writable_store="${shared_store}"
|
||||
store_scope=workspace
|
||||
else
|
||||
release_shared_lock
|
||||
fi
|
||||
else
|
||||
release_shared_lock
|
||||
fi
|
||||
fi
|
||||
if [[ "${store_scope}" != workspace ]]; then
|
||||
if [[ "${require_shared}" == 1 ]]; then
|
||||
echo "[sites] the workspace pnpm store is unavailable" >&2
|
||||
exit 69
|
||||
fi
|
||||
store_scope=project
|
||||
fi
|
||||
|
||||
seed="${SITES_PNPM_CACHE_SEED:-}"
|
||||
cache_seed=seed_unavailable
|
||||
if [[ -d "${writable_store}" ]]; then
|
||||
# Never merge a newer image seed into an existing mutable store.
|
||||
store_state=reused
|
||||
cache_seed=not_applicable
|
||||
if [[ -f "${writable_store}/.sites-pnpm-seed-applied.json" ]]; then cache_seed=seed_used; fi
|
||||
else
|
||||
seed_compatible=0
|
||||
if [[ -n "${seed}" && -d "${seed}/v11" ]] && node --input-type=module - "${seed}/.sites-pnpm-seed.json" <<'NODE'
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
try {
|
||||
const filename = process.argv[2];
|
||||
if (statSync(filename).size > 4096) process.exit(1);
|
||||
const seed = JSON.parse(readFileSync(filename, "utf8"));
|
||||
process.exit(seed.version === 1 && seed.pnpm_version === "11.25.0" &&
|
||||
seed.store_version === "v11" && /^[a-f0-9]{64}$/.test(seed.lockfile_sha256) ? 0 : 1);
|
||||
} catch { process.exit(1); }
|
||||
NODE
|
||||
then seed_compatible=1; fi
|
||||
if [[ "${seed_compatible}" == 1 ]]; then
|
||||
if [[ "${prepare_only}" == 1 ]]; then
|
||||
echo "[sites] copying the image seed into the writable ${store_scope} pnpm store" >&2
|
||||
else
|
||||
echo "[sites] copying the image seed into the writable ${store_scope} pnpm store"
|
||||
fi
|
||||
seed_stage="$(mktemp -d "${writable_store}.seed.XXXXXX")" || exit 70
|
||||
trap 'if [[ -n "${seed_stage:-}" ]]; then rm -rf -- "${seed_stage}"; fi; report_store || true' EXIT
|
||||
timeout --signal=TERM --kill-after="${SITES_INSTALL_KILL_AFTER:-15s}" \
|
||||
"${SITES_PNPM_STORE_PREPARE_TIMEOUT:-60s}" bash -c \
|
||||
'cp -a --no-preserve=ownership "$1/." "$2/" && chmod -R u+rwX "$2"' _ "${seed}" "${seed_stage}" || exit 70
|
||||
cp "${seed}/.sites-pnpm-seed.json" "${seed_stage}/.sites-pnpm-seed-applied.json" || exit 70
|
||||
# An ordinary pnpm command can initialize the store without our seed lock.
|
||||
# Publish without replacing even an empty directory created by that command.
|
||||
mv -T --update=none "${seed_stage}" "${writable_store}" || exit 70
|
||||
if [[ -d "${seed_stage}" ]]; then
|
||||
if [[ -L "${writable_store}" || ! -d "${writable_store}" ]]; then exit 78; fi
|
||||
rm -rf -- "${seed_stage}" || exit 70
|
||||
store_state=reused
|
||||
cache_seed=not_applicable
|
||||
if [[ -f "${writable_store}/.sites-pnpm-seed-applied.json" ]]; then cache_seed=seed_used; fi
|
||||
else
|
||||
store_state=seeded
|
||||
cache_seed=seed_used
|
||||
fi
|
||||
seed_stage=""
|
||||
else
|
||||
mkdir -p "${writable_store}" || exit 70
|
||||
store_state=created
|
||||
fi
|
||||
fi
|
||||
if ! can_write_directory "${writable_store}"; then store_state=unavailable; exit 70; fi
|
||||
release_shared_lock
|
||||
report_store
|
||||
trap - EXIT
|
||||
if [[ "${prepare_only}" == 1 ]]; then exit 0; fi
|
||||
|
||||
# Configuration travels with source without embedding an absolute machine path.
|
||||
# A later restricted session selects a private store before its frozen repair.
|
||||
configured_store=.sites-runtime/pnpm-store
|
||||
if [[ "${store_scope}" == workspace ]]; then
|
||||
configured_store='${SITES_PNPM_SHARED_STORE:-.sites-runtime/pnpm-store}'
|
||||
elif [[ "${runtime_root}" != "${SITES_PROJECT_ROOT}/.sites-runtime" ]]; then
|
||||
configured_store='${SITES_RUNTIME_ROOT:-.sites-runtime}/pnpm-store'
|
||||
fi
|
||||
"${pnpm_command[@]}" config set package-import-method clone-or-copy --location project
|
||||
"${pnpm_command[@]}" config set store-dir "${configured_store}" --location project
|
||||
"${pnpm_command[@]}" config set cache-dir "${configured_store}/policy-cache" --location project
|
||||
|
||||
# CI=true lets native pnpm install rebuild modules whose store moved, without a
|
||||
# prompt, --force, changing the lockfile, or translating the project into npm.
|
||||
CI=true timeout --signal=TERM --kill-after="${SITES_INSTALL_KILL_AFTER:-15s}" \
|
||||
"${SITES_INSTALL_TIMEOUT:-8m}" node "${script_dir}/pnpm-install.mjs" \
|
||||
"${cache_seed}" "${store_scope}" "${store_state}" "${writable_store}" "${pnpm_command[@]}"
|
||||
@@ -0,0 +1,261 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
ftruncateSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
} from "node:fs";
|
||||
import { constants as osConstants } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MAX_PACKAGES = 100_000;
|
||||
const CACHE_SEEDS = new Set(["seed_used", "seed_unavailable", "seed_lockfile_mismatch", "decision_unavailable", "not_applicable"]);
|
||||
const STORE_STATES = new Set(["created", "seeded", "reused", "unavailable"]);
|
||||
// The caller may use operational status only for
|
||||
// initial-setup fallback; an established project always retains pnpm. Broad
|
||||
// fetch failures can include TLS/auth errors and stay fatal.
|
||||
const OPERATIONAL_FAILURE_CODES = new Set([
|
||||
"ERR_PNPM_UNEXPECTED_STORE",
|
||||
"ERR_PNPM_UNEXPECTED_VIRTUAL_STORE",
|
||||
"ERR_PNPM_OUTDATED_LOCKFILE",
|
||||
"ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE",
|
||||
"ERR_PNPM_NO_LOCKFILE",
|
||||
"ERR_PNPM_FETCH_408",
|
||||
"ERR_PNPM_FETCH_429",
|
||||
"ERR_PNPM_FETCH_500",
|
||||
"ERR_PNPM_FETCH_502",
|
||||
"ERR_PNPM_FETCH_503",
|
||||
"ERR_PNPM_FETCH_504",
|
||||
]);
|
||||
|
||||
// These are pnpm-reported package IDs, not an assertion about network bytes or
|
||||
// image-cache provenance. Do not interpret an installed-tree no-op as 100% reuse.
|
||||
export class InstallProgress {
|
||||
constructor(project) {
|
||||
this.project = path.resolve(project);
|
||||
this.reused = new Set();
|
||||
this.downloaded = new Set();
|
||||
this.complete = false;
|
||||
this.added = undefined;
|
||||
this.invalid = false;
|
||||
}
|
||||
|
||||
accept(event) {
|
||||
if (event?.name === "pnpm:stats" && event.prefix === this.project &&
|
||||
Number.isSafeInteger(event.added) && event.added >= 0) this.added = event.added;
|
||||
if (event?.name === "pnpm:stage" && event.stage === "importing_done" &&
|
||||
event.prefix === this.project) this.complete = true;
|
||||
if (event?.name !== "pnpm:progress" || event.requester !== this.project ||
|
||||
!["fetched", "found_in_store"].includes(event.status)) return;
|
||||
if (typeof event.packageId !== "string" || !event.packageId || event.packageId.length > 4096 ||
|
||||
this.reused.size + this.downloaded.size >= MAX_PACKAGES) {
|
||||
this.invalid = true;
|
||||
return;
|
||||
}
|
||||
if (event.status === "fetched") {
|
||||
this.reused.delete(event.packageId);
|
||||
this.downloaded.add(event.packageId);
|
||||
} else if (!this.downloaded.has(event.packageId)) {
|
||||
this.reused.add(event.packageId);
|
||||
}
|
||||
}
|
||||
|
||||
counts(success) {
|
||||
if (!success || !this.complete || !(this.added > 0) || this.invalid ||
|
||||
this.reused.size + this.downloaded.size === 0) return {};
|
||||
return { packages_reused: this.reused.size, packages_downloaded: this.downloaded.size };
|
||||
}
|
||||
}
|
||||
|
||||
function openReport() {
|
||||
const report = process.env.SITES_INSTALL_REPORT_PATH;
|
||||
if (!report || !constants.O_NOFOLLOW) return undefined;
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(report, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
if (fstatSync(fd).isFile()) return fd;
|
||||
} catch {}
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function writeReport(fd, report) {
|
||||
if (fd === undefined) return;
|
||||
try {
|
||||
const body = `${JSON.stringify(report)}\n`;
|
||||
writeSync(fd, body, 0, "utf8");
|
||||
ftruncateSync(fd, Buffer.byteLength(body));
|
||||
} catch {
|
||||
// Optional telemetry must not change installation behavior or expose its path.
|
||||
}
|
||||
}
|
||||
|
||||
function showLine(line, progress, failure) {
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
process.stdout.write(`${line}\n`);
|
||||
return;
|
||||
}
|
||||
progress.accept(event);
|
||||
if (event?.level === "error" && failure.code !== 65) {
|
||||
failure.code = event.name === "pnpm" && OPERATIONAL_FAILURE_CODES.has(event.err?.code)
|
||||
? 70 : 65;
|
||||
}
|
||||
const message = event?.message ?? event?.err?.message;
|
||||
if (event?.name === "pnpm:lifecycle" && typeof event.line === "string") {
|
||||
process.stdout.write(`${event.line}\n`);
|
||||
} else if (typeof message === "string" && ["warn", "error", "info"].includes(event.level)) {
|
||||
const output = event.level === "error" ? process.stderr : process.stdout;
|
||||
output.write(`${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLock(filename, waitSeconds) {
|
||||
let fd;
|
||||
try {
|
||||
if (!filename || !constants.O_NOFOLLOW || !constants.O_NONBLOCK) throw new Error("Unsupported lock");
|
||||
fd = openSync(filename, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
if (!fstatSync(fd).isFile()) throw new Error("Expected a regular lock file");
|
||||
const child = spawn("flock", ["-w", waitSeconds, "3"], { stdio: ["ignore", "ignore", "ignore", fd] });
|
||||
const code = await new Promise((resolve) => {
|
||||
child.once("error", () => resolve(undefined));
|
||||
child.once("close", resolve);
|
||||
});
|
||||
if (code === 0) return { status: "locked", fd };
|
||||
closeSync(fd);
|
||||
return { status: code === undefined ? "unavailable" : "busy" };
|
||||
} catch {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
async function holdInstallLocks(projectLock, sharedLock, waitSeconds) {
|
||||
// The shell holds both leases through stdin. EOF (including caller exit)
|
||||
// releases the same open file descriptions that flock locked in its child.
|
||||
const requests = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
const project = await openLock(projectLock, "0");
|
||||
let shared;
|
||||
try {
|
||||
process.stdout.write(`${project.status}\n`);
|
||||
for await (const request of requests) {
|
||||
if (request === "shared" && project.fd !== undefined) {
|
||||
shared ??= await openLock(sharedLock, waitSeconds);
|
||||
process.stdout.write(`${shared.status}\n`);
|
||||
} else if (request === "release-shared") {
|
||||
if (shared?.fd !== undefined) closeSync(shared.fd);
|
||||
shared = undefined;
|
||||
process.stdout.write("released\n");
|
||||
} else {
|
||||
process.stdout.write("unavailable\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (shared?.fd !== undefined) closeSync(shared.fd);
|
||||
if (project.fd !== undefined) closeSync(project.fd);
|
||||
requests.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argsIn = process.argv.slice(2);
|
||||
if (argsIn[0] === "--hold-install-locks") {
|
||||
await holdInstallLocks(argsIn[1], argsIn[2], argsIn[3]);
|
||||
return;
|
||||
}
|
||||
if (argsIn[0] === "--report-store") {
|
||||
const [, cacheSeed, storeScope, storeState, stdout] = argsIn;
|
||||
if (!CACHE_SEEDS.has(cacheSeed) || !["project", "workspace", "unknown"].includes(storeScope) ||
|
||||
!STORE_STATES.has(storeState)) { process.exitCode = 64; return; }
|
||||
const report = { version: 1, cache_seed: cacheSeed, store_scope: storeScope, store_state: storeState };
|
||||
const fd = openReport();
|
||||
writeReport(fd, report);
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
if (stdout === "1") process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
return;
|
||||
}
|
||||
const [cacheSeed, storeScope, storeState, store, executable, ...prefix] = argsIn;
|
||||
if (!CACHE_SEEDS.has(cacheSeed) || !["project", "workspace"].includes(storeScope) ||
|
||||
!STORE_STATES.has(storeState) || storeState === "unavailable" ||
|
||||
!store || !path.isAbsolute(store) || !executable) {
|
||||
process.stderr.write("Invalid pnpm installation arguments.\n");
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
const fd = openReport();
|
||||
const report = { version: 1, cache_seed: cacheSeed, store_scope: storeScope, store_state: storeState };
|
||||
writeReport(fd, report);
|
||||
const progress = new InstallProgress(process.cwd());
|
||||
const failure = {};
|
||||
const env = { ...process.env };
|
||||
delete env.SITES_INSTALL_REPORT_PATH;
|
||||
const args = [...prefix, "install", "--prod=false", "--ignore-scripts=false",
|
||||
"--frozen-lockfile", "--prefer-offline", "--store-dir", store,
|
||||
"--cache-dir", path.join(store, "policy-cache"), "--fetch-retries=0",
|
||||
"--fetch-timeout=30000", "--network-concurrency=1", "--reporter=ndjson",
|
||||
"--package-import-method=clone-or-copy"];
|
||||
let result = { code: 1, signal: null };
|
||||
let receivedSignal;
|
||||
try {
|
||||
const child = spawn(executable, args, { env, stdio: ["inherit", "pipe", "inherit"] });
|
||||
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
lines.on("line", (line) => showLine(line, progress, failure));
|
||||
// timeout/exec owns the inherited group. Relaying would deliver signals twice.
|
||||
const signalHandlers = ["SIGINT", "SIGHUP", "SIGTERM"].map((signal) => {
|
||||
const handler = () => { receivedSignal ??= signal; };
|
||||
process.on(signal, handler);
|
||||
return [signal, handler];
|
||||
});
|
||||
let spawnError;
|
||||
child.once("error", (error) => { spawnError = error; });
|
||||
result = await new Promise((resolve) => child.once("close", (code, signal) => {
|
||||
resolve({ code: spawnError ? (spawnError.code === "ENOENT" ? 127 : 1) : code, signal });
|
||||
}));
|
||||
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
||||
lines.close();
|
||||
result.signal ??= receivedSignal;
|
||||
if (spawnError) {
|
||||
process.stderr.write("Unable to start pnpm.\n");
|
||||
result.code = spawnError.code === "ENOENT" ? 127 : 65;
|
||||
} else if (result.code !== 0 && !result.signal) {
|
||||
result.code = failure.code ?? 65;
|
||||
}
|
||||
if (result.code === 0 && !result.signal) {
|
||||
accessSync("node_modules/.bin/vinext", constants.X_OK);
|
||||
const lock = readFileSync("pnpm-lock.yaml");
|
||||
writeFileSync("node_modules/.sites-install.json", `${JSON.stringify({
|
||||
package_manager: "pnpm@11.25.0",
|
||||
lockfile_sha256: createHash("sha256").update(lock).digest("hex"),
|
||||
node: process.version,
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write("Dependency setup did not produce a usable Vinext installation.\n");
|
||||
result = { code: 65, signal: receivedSignal ?? null };
|
||||
} finally {
|
||||
Object.assign(report, progress.counts(result.code === 0 && !result.signal));
|
||||
writeReport(fd, report);
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
if (report.packages_reused !== undefined) {
|
||||
process.stdout.write(`[sites] pnpm reused ${report.packages_reused} packages and downloaded ${report.packages_downloaded}\n`);
|
||||
}
|
||||
if (result.code === 0 && !result.signal) process.stdout.write("[sites] dependency setup passed\n");
|
||||
process.exitCode = result.signal ? 128 + (osConstants.signals[result.signal] ?? 0) : result.code ?? 1;
|
||||
if (result.signal) {
|
||||
try { process.kill(process.pid, result.signal); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -0,0 +1,23 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readExecutionProfile } from "./execution-profile.mjs";
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (!["dev", "build"].includes(command)) throw new Error("Expected dev or build.");
|
||||
const managedLinux = readExecutionProfile() === "managed-linux";
|
||||
|
||||
if (managedLinux && command === "build") {
|
||||
const result = spawnSync("bash", [
|
||||
fileURLToPath(new URL("./build-verified.sh", import.meta.url)), ...args,
|
||||
], { stdio: "inherit" });
|
||||
if (result.error) throw result.error;
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Import in this process so the preview owner retains its PID and signals.
|
||||
const cli = new URL(managedLinux
|
||||
? "../node_modules/vite/bin/vite.js"
|
||||
: "../node_modules/vinext/dist/cli.js", import.meta.url);
|
||||
process.argv = [process.execPath, fileURLToPath(cli), command,
|
||||
...(!managedLinux && command === "dev" ? ["--port", "5173"] : []), ...args];
|
||||
await import(cli.href);
|
||||
@@ -0,0 +1,192 @@
|
||||
const base=(process.env.AZEROTHAI_URL||'http://127.0.0.1:8787').replace(/\/$/,'');
|
||||
const token=process.env.ALE_BRIDGE_TOKEN||'simulation-bridge-token';
|
||||
const autonomyToken=process.env.AUTONOMY_TOKEN||'simulation-autonomy-token';
|
||||
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const assert=(condition,message)=>{if(!condition)throw new Error(message);};
|
||||
|
||||
async function json(path,options={}){
|
||||
const r=await fetch(base+path,options);
|
||||
const text=await r.text();
|
||||
let body;
|
||||
try{body=text?JSON.parse(text):null;}catch{body={raw:text};}
|
||||
if(!r.ok)throw new Error(`${options.method||'GET'} ${path} -> ${r.status}: ${text.slice(0,1000)}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function ale(kind,payload={}){
|
||||
return json('/api/bridge/ale/event',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},
|
||||
body:JSON.stringify({kind,payload})
|
||||
});
|
||||
}
|
||||
|
||||
async function state(){return json('/api/state');}
|
||||
|
||||
console.log('Booting simulated live-world event sequence...');
|
||||
const initial=await state();
|
||||
assert(initial.bots.some(b=>b.id==='hero-zaku'),'Seeded Zaku is missing');
|
||||
assert(!initial.population?.lastPlan,'Population plan should be empty before authoritative human movement');
|
||||
|
||||
await ale('bridge.ready',{pollMs:750,protectedCharacter:'Zaku'});
|
||||
await ale('server.startup',{players:1});
|
||||
|
||||
await ale('player.login',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:85,areaId:159,isBot:true,zoneName:'Brill'
|
||||
});
|
||||
await ale('player.zone',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest'
|
||||
});
|
||||
|
||||
let afterBotMove=await state();
|
||||
assert(!afterBotMove.population?.lastPlan,'AI resident movement incorrectly triggered the Population Director');
|
||||
|
||||
await ale('player.combat.enter',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest',enemy:'Moonrage Glutton'
|
||||
});
|
||||
await ale('player.close-call',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest',healthBefore:23,incomingDamage:61
|
||||
});
|
||||
await ale('player.combat.leave',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest'
|
||||
});
|
||||
await ale('player.quest.accept',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest',quest:'The Dead Fields'
|
||||
});
|
||||
await ale('player.snapshot',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest'
|
||||
});
|
||||
|
||||
await ale('player.login',{
|
||||
name:'Durnak',characterGuid:9002,accountId:502,level:22,mapId:0,zoneId:40,areaId:40,isBot:true,zoneName:'Westfall'
|
||||
});
|
||||
await ale('player.zone',{
|
||||
name:'Durnak',characterGuid:9002,accountId:502,level:22,mapId:0,zoneId:40,areaId:40,isBot:true,zoneName:'Westfall'
|
||||
});
|
||||
await ale('player.combat.enter',{
|
||||
name:'Durnak',characterGuid:9002,accountId:502,level:22,mapId:0,zoneId:40,areaId:40,isBot:true,zoneName:'Westfall',enemy:'Defias Pillager'
|
||||
});
|
||||
await ale('player.killed-by-creature',{
|
||||
name:'Durnak',characterGuid:9002,accountId:502,level:22,mapId:0,zoneId:40,areaId:40,isBot:true,zoneName:'Westfall',killer:'Defias Pillager'
|
||||
});
|
||||
await ale('player.resurrect',{
|
||||
name:'Durnak',characterGuid:9002,accountId:502,level:22,mapId:0,zoneId:40,areaId:40,isBot:true,zoneName:'Westfall'
|
||||
});
|
||||
|
||||
await ale('player.killed-by-creature',{
|
||||
name:'Zaku',characterGuid:9001,accountId:501,level:14,mapId:0,zoneId:130,areaId:130,isBot:true,zoneName:'Silverpine Forest',killer:'Moonrage Glutton'
|
||||
});
|
||||
|
||||
await ale('player.login',{
|
||||
name:'Player',level:12,mapId:0,zoneId:10,areaId:10,isBot:false,zoneName:'Duskwood'
|
||||
});
|
||||
await ale('player.zone',{
|
||||
name:'Player',level:12,mapId:0,zoneId:10,areaId:10,isBot:false,zoneName:'Duskwood'
|
||||
});
|
||||
|
||||
await sleep(250);
|
||||
|
||||
const autonomy=await json('/api/autonomy/tick',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json',Authorization:`Bearer ${autonomyToken}`},
|
||||
body:'{}'
|
||||
});
|
||||
assert(autonomy.ran===true,'Autonomy heartbeat did not select a live managed Playerbot');
|
||||
assert(autonomy.name==='Zaku',`Expected Zaku to receive autonomous intent, got ${autonomy.name}`);
|
||||
|
||||
const polled=await fetch(base+'/api/bridge/ale/poll',{
|
||||
headers:{Authorization:`Bearer ${token}`}
|
||||
});
|
||||
assert(polled.status===200,`ALE bridge did not receive queued autonomous command: HTTP ${polled.status}`);
|
||||
const commandId=polled.headers.get('x-azerothai-command');
|
||||
const lua=await polled.text();
|
||||
assert(commandId,'Autonomous ALE command had no command id');
|
||||
assert(lua.includes('GetPlayerByName("Zaku")'),'Autonomous ALE command did not target Zaku');
|
||||
assert(lua.includes(':GetBotAI()'),'Autonomous ALE command did not route through PlayerBotAI');
|
||||
await json('/api/bridge/ale/result',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},
|
||||
body:JSON.stringify({id:commandId,ok:true,result:'simulated PlayerBotAI intent accepted'})
|
||||
});
|
||||
|
||||
const finalState=await state();
|
||||
const monitor=await json('/api/world-monitor');
|
||||
|
||||
const zaku=finalState.bots.find(b=>b.id==='hero-zaku');
|
||||
const durnak=finalState.bots.find(b=>b.id==='bot-2');
|
||||
|
||||
assert(zaku,'Zaku disappeared');
|
||||
assert(durnak,'Durnak disappeared');
|
||||
assert(zaku.status!=='dead','Zaku entered the death state');
|
||||
assert(zaku.zone==='Silverpine Forest',`Zaku authoritative zone did not sync: ${zaku.zone}`);
|
||||
assert(zaku.level===14,`Zaku authoritative level did not sync: ${zaku.level}`);
|
||||
assert(zaku.autonomy?.managed===true,'Zaku was not adopted as a managed live resident');
|
||||
assert(zaku.autonomy?.characterGuid===9001,`Zaku character GUID binding was not persisted: ${zaku.autonomy?.characterGuid}`);
|
||||
assert(zaku.autonomy?.accountId===501,`Zaku account binding was not persisted: ${zaku.autonomy?.accountId}`);
|
||||
assert(zaku.decisions>=1,'Autonomous heartbeat did not persist a Zaku decision');
|
||||
assert(zaku.memories.some(m=>m.source==='observation'&&/barely survived/i.test(m.text)),'Zaku did not retain a close-call memory');
|
||||
assert(zaku.memories.some(m=>/accepted The Dead Fields/i.test(m.text)),'Zaku did not retain an authoritative quest memory');
|
||||
assert(durnak.status==='dead',`Durnak should be permanently dead, got ${durnak.status}`);
|
||||
assert(durnak.death?.verified===true,'Durnak death was not verified');
|
||||
assert(durnak.death?.source==='azerothcore',`Durnak death source should be azerothcore, got ${durnak.death?.source}`);
|
||||
assert(durnak.death?.killer==='Defias Pillager','Durnak killer was not preserved');
|
||||
assert(zaku.memories.some(m=>/Durnak died permanently/i.test(m.text)),'Zaku did not receive memorial memory for a known realm death');
|
||||
assert(finalState.population?.lastPlan?.trigger?.zone==='Duskwood','Human movement did not drive the Population Director');
|
||||
assert(finalState.population?.lastPlan?.trigger?.playerName==='Player','Human player name was not preserved in population trigger');
|
||||
assert(monitor.ale?.bridgeReady===true,'World Monitor did not see bridge.ready');
|
||||
assert(monitor.ale?.activeRecently===true,'World Monitor did not mark recent ALE activity live');
|
||||
assert((monitor.playerbots?.observedBots??0)>=2,'World Monitor did not observe both live Playerbot identities');
|
||||
assert(monitor.events.some(e=>e.kind==='player.close-call'&&e.name==='Zaku'),'World Monitor missed Zaku close-call ALE event');
|
||||
assert(monitor.events.some(e=>e.kind==='player.killed-by-creature'&&e.name==='Durnak'),'World Monitor missed authoritative death ALE event');
|
||||
assert(finalState.events.some(e=>e.kind==='error'&&/Plot armor breach/.test(e.message)&&e.botId==='hero-zaku'),'Zaku kill report did not produce a plot-armor breach alert');
|
||||
assert(finalState.events.some(e=>e.kind==='error'&&/permanently dead resident Durnak/.test(e.message)),'Hardcore resurrection attempt was not rejected in state');
|
||||
|
||||
const report={
|
||||
result:'PASS',
|
||||
zaku:{
|
||||
status:zaku.status,
|
||||
level:zaku.level,
|
||||
zone:zaku.zone,
|
||||
closeCallRemembered:zaku.memories.some(m=>/barely survived/i.test(m.text)),
|
||||
rememberedDurnakDeath:zaku.memories.some(m=>/Durnak died permanently/i.test(m.text)),
|
||||
plotArmorBreachAlerted:finalState.events.some(e=>e.kind==='error'&&/Plot armor breach/.test(e.message)&&e.botId==='hero-zaku'),
|
||||
characterGuid:zaku.autonomy?.characterGuid,
|
||||
autonomousDecisions:zaku.decisions,
|
||||
lastDecision:zaku.lastDecision?.action
|
||||
},
|
||||
durnak:{
|
||||
status:durnak.status,
|
||||
verifiedDeath:durnak.death?.verified,
|
||||
deathSource:durnak.death?.source,
|
||||
killer:durnak.death?.killer,
|
||||
resurrectionRejected:finalState.events.some(e=>e.kind==='error'&&/permanently dead resident Durnak/.test(e.message))
|
||||
},
|
||||
population:{
|
||||
trigger:finalState.population?.lastPlan?.trigger?.kind,
|
||||
zone:finalState.population?.lastPlan?.trigger?.zone,
|
||||
playerName:finalState.population?.lastPlan?.trigger?.playerName,
|
||||
selected:finalState.population?.lastPlan?.selectedBotIds?.length??0
|
||||
},
|
||||
worldMonitor:{
|
||||
bridgeReady:monitor.ale?.bridgeReady,
|
||||
activeRecently:monitor.ale?.activeRecently,
|
||||
observedBotEvents:monitor.playerbots?.observedBots,
|
||||
visibleAleEvents:monitor.events.length
|
||||
},
|
||||
checks:[
|
||||
'AI movement does not trigger human-centered population activation',
|
||||
'authoritative bot zone and level sync back into AzerothAI',
|
||||
'Zaku close call becomes a diegetic memory',
|
||||
'ordinary Hardcore death is permanent and verified',
|
||||
'dead resident resurrection is rejected by AzerothAI state',
|
||||
'Zaku cannot enter the death state even when a bad authoritative kill event arrives',
|
||||
'human movement drives the Population Director',
|
||||
'World Monitor receives live-style ALE telemetry',
|
||||
'live resident identity is bound to authoritative character GUID/account',
|
||||
'autonomy heartbeat queues a real PlayerBotAI command through ALE'
|
||||
]
|
||||
};
|
||||
|
||||
console.log('\n=== AZEROTHAI LIVE-WORLD SIMULATION ===');
|
||||
console.log(JSON.stringify(report,null,2));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const runtimeRoot = process.env.SITES_RUNTIME_ROOT || path.join(projectRoot, ".sites-runtime");
|
||||
|
||||
process.env.CLOUDFLARE_CF_FETCH_ENABLED ||= "false";
|
||||
process.env.WRANGLER_SEND_METRICS ||= "false";
|
||||
process.env.WRANGLER_WRITE_LOGS ||= "false";
|
||||
process.env.WRANGLER_LOG_PATH ||= path.join(runtimeRoot, "wrangler/logs");
|
||||
process.env.WRANGLER_REGISTRY_PATH ||= path.join(runtimeRoot, "wrangler/dev-registry");
|
||||
process.env.MINIFLARE_REGISTRY_PATH ||= path.join(runtimeRoot, "wrangler/registry");
|
||||
|
||||
process.chdir(projectRoot);
|
||||
for (const directory of [
|
||||
path.dirname(process.env.WRANGLER_LOG_PATH),
|
||||
process.env.WRANGLER_REGISTRY_PATH,
|
||||
process.env.MINIFLARE_REGISTRY_PATH,
|
||||
]) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
const rootDevVars = path.join(projectRoot, ".dev.vars");
|
||||
const builtConfigDir = path.join(projectRoot, "dist/server");
|
||||
const builtDevVars = path.join(builtConfigDir, ".dev.vars");
|
||||
if (existsSync(rootDevVars) && existsSync(builtConfigDir)) {
|
||||
copyFileSync(rootDevVars, builtDevVars);
|
||||
try { chmodSync(builtDevVars, 0o600); } catch {}
|
||||
}
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
runtime_root="${SITES_RUNTIME_ROOT:-${project_root}/.sites-runtime}"
|
||||
|
||||
mkdir -p \
|
||||
"${runtime_root}/home" \
|
||||
"${runtime_root}/npm-cache" \
|
||||
"${runtime_root}/xdg-config" \
|
||||
"${runtime_root}/tmp" \
|
||||
"${runtime_root}/wrangler/logs"
|
||||
|
||||
export SITES_ENV_READY=1
|
||||
export SITES_PROJECT_ROOT="${project_root}"
|
||||
export HOME="${runtime_root}/home"
|
||||
export XDG_CONFIG_HOME="${runtime_root}/xdg-config"
|
||||
export TMPDIR="${runtime_root}/tmp"
|
||||
export WRANGLER_WRITE_LOGS=false
|
||||
export WRANGLER_LOG_PATH="${runtime_root}/wrangler/logs"
|
||||
export MINIFLARE_REGISTRY_PATH="${runtime_root}/wrangler/registry"
|
||||
|
||||
# The runtime may provide a global npm cache. Keep the image's read-only Sites
|
||||
# seed separate and make this project's writable cache authoritative.
|
||||
unset NPM_CONFIG_CACHE npm_config_cache || true
|
||||
export npm_config_cache="${runtime_root}/npm-cache"
|
||||
export npm_config_audit=false
|
||||
export npm_config_fund=false
|
||||
export npm_config_update_notifier=false
|
||||
|
||||
# The runtime already supplies the standard HTTP(S)_PROXY variables. Remove
|
||||
# npm-specific aliases so npm 11 does not reinterpret or warn about them.
|
||||
unset \
|
||||
npm_config_proxy \
|
||||
npm_config_http_proxy \
|
||||
npm_config_https_proxy \
|
||||
NPM_CONFIG_PROXY \
|
||||
NPM_CONFIG_HTTP_PROXY \
|
||||
NPM_CONFIG_HTTPS_PROXY \
|
||||
|| true
|
||||
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
echo "usage: scripts/sites-env.sh -- command [args...]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
cd "${project_root}"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,45 @@
|
||||
const base=(process.env.OLLAMA_URL||'http://127.0.0.1:11434').replace(/\/$/,'');
|
||||
const model=process.env.MODEL||'qwen3.5:4b';
|
||||
|
||||
const scenario={
|
||||
identity:{name:'Zaku',race:'Undead',role:'Warlock',tier:'hero'},
|
||||
personality:{name:'Zaku · Persistent Hero',description:'Curious, independent, loyal, and cautious when badly hurt.',activities:['explore','quest','socialize','trade','rest']},
|
||||
goal:'Survive and decide what to do after a near-death fight.',
|
||||
memories:[
|
||||
{text:'I have a room in Brill that I consider home.',importance:10},
|
||||
{text:'I barely survived a dangerous fight in Silverpine.',importance:10}
|
||||
],
|
||||
relationships:[],
|
||||
observation:{
|
||||
zone:'Silverpine Forest',
|
||||
level:12,
|
||||
summary:'You are badly injured, low on resources, and the road back toward Brill is open.',
|
||||
alive:true,
|
||||
availableActions:['explore','quest','rest']
|
||||
},
|
||||
allowed:['explore','quest','rest']
|
||||
};
|
||||
|
||||
const messages=[
|
||||
{role:'system',content:'You choose one high-level intention for a person living in Azeroth. Stay in character. Return only JSON {"action":"explore|quest|socialize|trade|rest","reason":"brief in-character rationale","goal":"short goal"}. Use only an allowed action.'},
|
||||
{role:'user',content:JSON.stringify(scenario)}
|
||||
];
|
||||
|
||||
const started=Date.now();
|
||||
const response=await fetch(base+'/api/chat',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({model,messages,stream:false,format:'json',think:false,options:{temperature:0.4,num_predict:160}})
|
||||
});
|
||||
if(!response.ok){
|
||||
throw new Error('Ollama returned HTTP '+response.status+': '+await response.text());
|
||||
}
|
||||
const data=await response.json();
|
||||
const raw=data.message?.content;
|
||||
if(typeof raw!=='string'||!raw.trim())throw new Error('Ollama returned no final message content: '+JSON.stringify(data).slice(0,1200));
|
||||
let parsed;
|
||||
try{parsed=JSON.parse(raw);}catch{throw new Error('Model did not return valid JSON: '+raw);}
|
||||
if(!scenario.allowed.includes(parsed.action))throw new Error('Model chose disallowed action: '+String(parsed.action));
|
||||
if(typeof parsed.reason!=='string'||!parsed.reason.trim())throw new Error('Missing reason');
|
||||
if(typeof parsed.goal!=='string'||!parsed.goal.trim())throw new Error('Missing goal');
|
||||
console.log(JSON.stringify({ok:true,model,latencyMs:Date.now()-started,decision:parsed},null,2));
|
||||
@@ -0,0 +1,6 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {writeFileSync,mkdirSync} from 'node:fs';
|
||||
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
|
||||
if(result.status)process.exit(result.status);
|
||||
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
|
||||
process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs'],{stdio:'inherit'}).status??1);
|
||||
Reference in New Issue
Block a user