Add operator inspection and live turn pressure

This commit is contained in:
Meta Vens 2026-08-16 21:03:18 +02:00
commit 381cbf22c2
10 changed files with 146 additions and 16 deletions

View file

@ -265,6 +265,17 @@ pub struct CampaignLifecycleReceipt {
pub created_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct RejectedProposalReceipt {
pub schema: String,
pub id: String,
pub campaign_id: Uuid,
pub revision: u64,
pub command_kind: String,
pub reason: String,
pub rejected_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct ActionIntent {
pub actor_id: String,

View file

@ -11,8 +11,8 @@ use ghostlight_dungeon::{
assessor::ActionAssessor,
compiler::{CustomStart, OpeningRequest, OpeningSuggestion, SelectedStart, WorldCompiler},
domain::{
ActionIntent, Campaign, NarrationProjection, RegionExpansionPreview, WorldCommand,
WorldCompilePreview,
ActionIntent, Campaign, NarrationProjection, RegionExpansionPreview,
RejectedProposalReceipt, WorldCommand, WorldCompilePreview,
},
gestalt::GestaltPresencePlanner,
kernel::CommandResult,
@ -33,6 +33,7 @@ use std::{
net::SocketAddr,
path::PathBuf,
sync::Arc,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
@ -49,6 +50,20 @@ struct AppState {
model: Option<Arc<dyn ModelPort>>,
compile_previews: Arc<Mutex<BTreeMap<String, OwnedPreview<WorldCompilePreview>>>>,
expansion_previews: Arc<Mutex<BTreeMap<String, OwnedPreview<RegionExpansionPreview>>>>,
live_turns: Arc<AtomicUsize>,
}
struct LiveTurnGuard(Arc<AtomicUsize>);
impl LiveTurnGuard {
fn enter(counter: &Arc<AtomicUsize>) -> Self {
counter.fetch_add(1, Ordering::SeqCst);
Self(counter.clone())
}
}
impl Drop for LiveTurnGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[derive(Clone)]
@ -180,6 +195,7 @@ async fn main() -> anyhow::Result<()> {
model: shared_model,
compile_previews: Arc::new(Mutex::new(BTreeMap::new())),
expansion_previews: Arc::new(Mutex::new(BTreeMap::new())),
live_turns: Arc::new(AtomicUsize::new(0)),
};
tokio::spawn(scheduler_loop(state.clone()));
let release_web_root = std::env::current_exe()?
@ -211,6 +227,7 @@ async fn main() -> anyhow::Result<()> {
.route("/api/campaigns/fork", post(fork_campaign))
.route("/api/campaigns/reset", post(reset_campaign))
.route("/api/campaigns/export", get(export_campaign))
.route("/api/operator", get(operator_inspector))
.fallback_service(ServeDir::new(web_root).append_index_html_on_directories(true))
.with_state(state);
let address: SocketAddr = std::env::var("GHOSTLIGHT_DUNGEON_BIND")
@ -299,6 +316,7 @@ async fn compile_openings(
if !authorized(&headers, &state).await {
return StatusCode::UNAUTHORIZED.into_response();
}
let _live = LiveTurnGuard::enter(&state.live_turns);
let Some(compiler) = &state.compiler else {
return (
StatusCode::SERVICE_UNAVAILABLE,
@ -321,6 +339,7 @@ async fn compile_custom(
Some(value) => value,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let _live = LiveTurnGuard::enter(&state.live_turns);
if session_runtime(&state, &session)
.await
.ok()
@ -365,6 +384,7 @@ async fn compile_roles(
if !authorized(&headers, &state).await {
return StatusCode::UNAUTHORIZED.into_response();
}
let _live = LiveTurnGuard::enter(&state.live_turns);
let Some(compiler) = &state.compiler else {
return (
StatusCode::SERVICE_UNAVAILABLE,
@ -387,6 +407,7 @@ async fn compile_selected(
Some(value) => value,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let _live = LiveTurnGuard::enter(&state.live_turns);
if session_runtime(&state, &session)
.await
.ok()
@ -443,6 +464,7 @@ async fn approve_preview(
Some(value) => value,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let _live = LiveTurnGuard::enter(&state.live_turns);
let mut previews = state.compile_previews.lock().await;
let Some(owned) = previews.get(&preview_id).cloned() else {
return (StatusCode::NOT_FOUND, "preview missing or already consumed").into_response();
@ -487,6 +509,7 @@ async fn compile_destination(
Some(value) => value,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let _live = LiveTurnGuard::enter(&state.live_turns);
let runtime = match session_runtime(&state, &session).await {
Ok(Some(value)) => value,
Ok(None) => {
@ -677,6 +700,7 @@ async fn command(
Some(value) => value,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let _live = LiveTurnGuard::enter(&state.live_turns);
let runtime = match session_runtime(&state, &session).await {
Ok(Some(value)) => value,
Ok(None) => {
@ -759,6 +783,15 @@ async fn command(
&command,
WorldCommand::Attempt { .. } | WorldCommand::Speak { .. }
);
let command_kind = serde_json::to_value(&command)
.ok()
.and_then(|value| {
value
.get("type")
.and_then(|value| value.as_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "unknown".into());
match runtime.kernel.command(command).await {
Ok(result) => {
if should_react {
@ -918,13 +951,32 @@ async fn command(
Json(result).into_response()
}
}
Err(error) => (
StatusCode::CONFLICT,
Json(ErrorBody {
error: error.to_string(),
}),
)
.into_response(),
Err(error) => {
if let Ok(campaign) = load_campaign(&runtime.store) {
let receipt = RejectedProposalReceipt {
schema: "ghostlight.rejected_proposal_receipt.v1".into(),
id: uuid::Uuid::new_v4().to_string(),
campaign_id: campaign.id,
revision: campaign.revision,
command_kind,
reason: error.to_string(),
rejected_at: chrono::Utc::now(),
};
let _ = runtime.store.insert(
"rejected_proposal_receipt.v1",
"ghostlight.rejected_proposal_receipt.v1",
&receipt.id,
&receipt,
);
}
(
StatusCode::CONFLICT,
Json(ErrorBody {
error: error.to_string(),
}),
)
.into_response()
}
}
}
@ -934,6 +986,9 @@ async fn scheduler_loop(state: AppState) {
pulse.tick().await;
loop {
pulse.tick().await;
if state.live_turns.load(Ordering::SeqCst) > 0 {
continue;
}
for id in state.registry.list().await {
match state.registry.runtime(id).await {
Ok(runtime) => {
@ -1139,6 +1194,45 @@ async fn export_campaign(headers: HeaderMap, State(state): State<AppState>) -> R
}
}
async fn operator_inspector(headers: HeaderMap, State(state): State<AppState>) -> Response {
let session = match authenticated_session(&headers, &state).await {
Some(value) => value,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let runtime = match session_runtime(&state, &session).await {
Ok(Some(value)) => value,
Ok(None) => {
return (StatusCode::NOT_FOUND, "session has no selected campaign").into_response();
}
Err(error) => {
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
}
};
let campaign = match load_campaign(&runtime.store) {
Ok(value) => value,
Err(error) => {
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
}
};
let evidence = runtime
.store
.load_all::<ghostlight_dungeon::domain::VaultEvidenceReceipt>("vault_evidence_receipt.v1")
.unwrap_or_default();
let commits = runtime
.store
.load_all::<ghostlight_dungeon::domain::WorldCommitReceipt>("world_commit_receipt.v1")
.unwrap_or_default();
let stages = runtime
.store
.load_all::<ghostlight_dungeon::model::ModelStageReceipt>("persona_stage_receipt.v1")
.unwrap_or_default();
let rejected = runtime
.store
.load_all::<RejectedProposalReceipt>("rejected_proposal_receipt.v1")
.unwrap_or_default();
Json(serde_json::json!({"schema":"ghostlight.operator_inspector.v1","campaign":campaign,"evidence":evidence,"commit_receipts":commits,"model_stage_receipts":stages,"rejected_proposals":rejected,"scheduler":{"live_turn_pressure":state.live_turns.load(Ordering::SeqCst)}})).into_response()
}
async fn process_due_ticks(runtime: &CampaignRuntime) -> anyhow::Result<()> {
loop {
let campaign = match load_campaign(&runtime.store) {
@ -1379,6 +1473,7 @@ mod tests {
model: None,
compile_previews: Arc::new(Mutex::new(BTreeMap::new())),
expansion_previews: Arc::new(Mutex::new(BTreeMap::new())),
live_turns: Arc::new(AtomicUsize::new(0)),
};
let left_runtime = session_runtime(&state, "left").await.unwrap().unwrap();
let right_runtime = session_runtime(&state, "right").await.unwrap().unwrap();

View file

@ -30,6 +30,15 @@ impl CampaignStore {
.collect())
}
pub fn load_all<T: DeserializeOwned>(&self, kind: &str) -> Result<Vec<T>> {
self.inner
.pull_all()?
.into_iter()
.filter(|row| row.r#type == kind)
.map(|row| rmp_serde::from_slice(&row.payload).context("decode CultCache row"))
.collect()
}
pub fn load<T: DeserializeOwned>(
&self,
kind: &str,

View file

@ -593,6 +593,19 @@ campaign management, fork/reset/export, and optional operator inspection.
CultMesh state is the source of the UI projection. HTTP health is a probe over
the same service-state document.
The authenticated operator inspector projects the selected campaign's full
typed state, topology, evidence receipts, commit receipts, model-stage receipts,
rejected-proposal receipts, and scheduler live-turn pressure. It contains no
provider reasoning content or secret material. Kernel command refusals append a
private receipt without changing campaign revision, allowing the laboratory to
inspect why impossible, stale, or malformed proposals were rejected.
Live compiler and player command paths hold a process-local inference-pressure
lease. The five-minute scheduler observes that pressure and launches no new
campaign work while any live lease exists. Already-committed campaign state
does not require a repair pass when background launch is skipped; the next
scheduler pulse or return catch-up uses the same strategic-tick command.
## Model boundary
The provider-neutral model port records provider, model, request hash, source

View file

@ -169,6 +169,7 @@ stake prose alone never impersonates world mutation.
- `persona_stage_receipt.v1`, `actor_state_delta.v1`, `world_action_proposal.v1`
- `narration_projection.v1`
- `strategic_tick.v1`, `news_issue.v1`, `canon_candidate.v1`
- `rejected_proposal_receipt.v1`, `campaign_lifecycle_receipt.v1`
- `gestalt_persona_state.v1`, `gestalt_member_delta.v1`,
`gestalt_materialization_receipt.v1`
- Existing `gamecult.eve.surface.v1` and `gamecult.eve.command.v1`

View file

@ -255,5 +255,5 @@ data_flow:
current_status:
summary: GhostlightDungeon is the active MVP program. The approval-gated compiler persists evidence, canon candidates, and bounded region expansion. Fiction-first player and NPC attempts use deterministic initiative, model assessment, server-side d20, and prevalidated typed outcome deltas. Present NPCs appraise events in parallel; gestalt populations materialize durable individuals and fold them into member deltas. Narration is a revision-bound noncanonical projection. A CampaignRegistry now isolates per-campaign stores/mailboxes, persists hashed session ownership and selection, routes scheduler and HTTP work through the selected runtime, and supports fork/reset/select/CultCache export. Live DeepSeek plus VoidBot compilation and an earlier authenticated HTTP restart smoke passed; the expanded multi-campaign runtime still needs live acceptance.
next_action: Add operator inspection and live-inference priority gating, then run two-session HTTP, model-failure, restart, scheduler, live gestalt, typed-outcome, performance, and deployment acceptance against the registry runtime.
next_action: Run two-session HTTP, model-failure, restart, scheduler, live gestalt, typed-outcome, performance, and deployment acceptance against the registry runtime; repair any failures before updating deployment provenance.
gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization commands compose and demote active actors without deleting member identity.

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GhostlightDungeon</title> <script type="module" crossorigin src="/assets/index-CSZpdeJl.js"></script>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GhostlightDungeon</title> <script type="module" crossorigin src="/assets/index-DDHZWg6R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dt7lvyL0.css">
</head>
<body><header><p class="eyebrow">STARFIRE / PRIVATE VERSE</p><h1>GhostlightDungeon</h1><p id="status">Reading the world…</p></header><main>
@ -9,5 +9,5 @@
</div><div id="compiler-results"></div></section><section id="surface"></section>
<form id="composer"><label for="attempt">Describe what you attempt</label><textarea id="attempt" required></textarea><div><button type="submit">Assess</button><button type="button" id="wait">Wait one hour</button></div></form><pre id="receipt" hidden></pre>
<form id="destination-form"><label>Compile a destination without moving there</label><input name="origin_location_id" placeholder="Current location id" required><input name="destination" placeholder="Where do you want the world to extend?" required><button type="submit">Retrieve destination</button></form>
<section id="campaign-lab" hidden><h2>Campaign laboratory</h2><div id="campaign-list"></div><form id="fork-form"><input name="name" placeholder="Fork name" required><button type="submit">Fork selected campaign</button></form><form id="reset-form"><input name="name" placeholder="Reset branch name" required><button type="submit">Reset from approved seed</button></form><a href="/api/campaigns/export" download>Export selected CultCache .cc</a></section>
<section id="campaign-lab" hidden><h2>Campaign laboratory</h2><div id="campaign-list"></div><form id="fork-form"><input name="name" placeholder="Fork name" required><button type="submit">Fork selected campaign</button></form><form id="reset-form"><input name="name" placeholder="Reset branch name" required><button type="submit">Reset from approved seed</button></form><a href="/api/campaigns/export" download>Export selected CultCache .cc</a><details><summary>Operator inspector</summary><button type="button" id="load-operator">Load private state and receipts</button><pre id="operator-output"></pre></details></section>
</main></body></html>

View file

@ -7,5 +7,5 @@
</div><div id="compiler-results"></div></section><section id="surface"></section>
<form id="composer"><label for="attempt">Describe what you attempt</label><textarea id="attempt" required></textarea><div><button type="submit">Assess</button><button type="button" id="wait">Wait one hour</button></div></form><pre id="receipt" hidden></pre>
<form id="destination-form"><label>Compile a destination without moving there</label><input name="origin_location_id" placeholder="Current location id" required><input name="destination" placeholder="Where do you want the world to extend?" required><button type="submit">Retrieve destination</button></form>
<section id="campaign-lab" hidden><h2>Campaign laboratory</h2><div id="campaign-list"></div><form id="fork-form"><input name="name" placeholder="Fork name" required><button type="submit">Fork selected campaign</button></form><form id="reset-form"><input name="name" placeholder="Reset branch name" required><button type="submit">Reset from approved seed</button></form><a href="/api/campaigns/export" download>Export selected CultCache .cc</a></section>
<section id="campaign-lab" hidden><h2>Campaign laboratory</h2><div id="campaign-list"></div><form id="fork-form"><input name="name" placeholder="Fork name" required><button type="submit">Fork selected campaign</button></form><form id="reset-form"><input name="name" placeholder="Reset branch name" required><button type="submit">Reset from approved seed</button></form><a href="/api/campaigns/export" download>Export selected CultCache .cc</a><details><summary>Operator inspector</summary><button type="button" id="load-operator">Load private state and receipts</button><pre id="operator-output"></pre></details></section>
</main><script type="module" src="/src/main.ts"></script></body></html>

View file

@ -64,4 +64,5 @@ document.querySelector<HTMLButtonElement>("#wait")!.addEventListener("click", ()
destinationForm.addEventListener("submit",async event=>{event.preventDefault();const result=await compilerPost("/api/compiler/destination",Object.fromEntries(new FormData(destinationForm)));const preview=result.preview;receipt.hidden=false;receipt.textContent=JSON.stringify(preview,null,2);status.textContent="Destination preview compiled; topology is unchanged until approval.";const button=document.createElement("button");button.textContent="Approve destination";button.addEventListener("click",async()=>{await compilerPost(`/api/compiler/destination/approve/${result.preview_id}`,{});button.remove();await refresh();});destinationForm.append(button);});
document.querySelector<HTMLFormElement>("#fork-form")!.addEventListener("submit",async event=>{event.preventDefault();await compilerPost("/api/campaigns/fork",Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement)));await refresh();});
document.querySelector<HTMLFormElement>("#reset-form")!.addEventListener("submit",async event=>{event.preventDefault();await compilerPost("/api/campaigns/reset",Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement)));await refresh();});
document.querySelector<HTMLButtonElement>("#load-operator")!.addEventListener("click",async()=>{const response=await fetch("/api/operator");document.querySelector<HTMLElement>("#operator-output")!.textContent=JSON.stringify(await response.json(),null,2);});
void refresh();