Add operator inspection and live turn pressure
This commit is contained in:
parent
8defc96133
commit
381cbf22c2
10 changed files with 146 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
4
web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue