Land Cut 12: gate the answer path, recover pre-Cut-11 play stores (PA.f169, PA.f172, PA.f173, PA.f175)
PA.f169 (blocker): two more `client_intents` callers
(`world_create_seed_activate_and_play_round_trip_through_the_real_client`,
`a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted`)
ended in `.expect("node must be on PATH ...")` with no skip guard, so
`cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon` itself
failed in a `node`-less container -- Idunn's own gate. Both now carry the
same `eve_client_bridge_is_available()` skip guard the third caller already
had, printing a plain SKIPPED line naming themselves. Verified with `node`
scrubbed from PATH: all three skip and the full bin suite is still
156 passed, 0 failed.
PA.f172: `question_surface_version` (added in Cut 11 with no
`#[serde(default)]`) made a pre-Cut-11 `PlayTurnStore` row fail to decode,
which `PlayTurnStore::open` reported as "corrupt" and `runtime.rs` turned
into a silently missing play card. `STORE_SCHEMA` moves to
`ghostlight.play_turn_store.v2`; `question_surface_version` now carries
`#[serde(default)]`, so the same struct is a compatible read of a genuine
v1 row (`LEGACY_STORE_SCHEMA_V1`). `PlayTurnStore::open` recognizes that
schema id as legacy, not corrupt, recovers the turn, and upgrades the row
to v2 in place and durably. The strict canonical re-encode check still
applies to current-schema rows; a v1 row's bytes were never canonical
under v2 and the legacy branch does not attempt the comparison. A row that
matches the current schema id but is genuinely undecodable is still
refused as corrupt. `runtime.rs::runtime_readiness` now reports
`playStatus` ("ok"/"unavailable") on `/health`, the same
`Option -> ok/unavailable` shape `projectionStatus`/`controllerStatus`
already use, so a play table that failed to open is visible there instead
of only in a startup `tracing::warn!`.
PA.f173: `close_with_fault` left `question`/`question_surface_version` set
on the turn it closed. Unobservable through `open_question_id` only
because it gates on `state == AwaitingPlayer`, which closing always
leaves behind in the same write. `close_with_fault` now clears both,
matching `answer_turn`.
PA.f175: `execute_round` took one snapshot at round entry and reused it
for every arm in that round, so a round that authors (a `PLAY_TOOLS` run)
and then asks in the same round recorded `question_surface_version` from
before its own commit. The `ASK_PLAYER_TOOL` arm now fetches a fresh
snapshot at the moment the question actually opens.
Tests added: a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt,
a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt,
close_with_fault_clears_the_open_question_off_the_persisted_row,
a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round,
health_reports_play_status_from_state_play.
Dungeon bin: 156 passed, 0 failed (bridge available); 156 passed, 0 failed
with `node` scrubbed from PATH (3 SKIPPED).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5b1f55c30c
commit
7052644467
2 changed files with 452 additions and 14 deletions
|
|
@ -43,7 +43,20 @@ use sha2::{Digest, Sha256};
|
|||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
const STORE_TYPE: &str = "ghostlight.play_turn_store.v1";
|
||||
const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v1";
|
||||
/// PA.f172: bumped from `ghostlight.play_turn_store.v1` when
|
||||
/// `question_surface_version` was added to `PlayTurn` without
|
||||
/// `#[serde(default)]` — the row's own shape moved, so its schema id moves
|
||||
/// with it. `question_surface_version` now carries `#[serde(default)]`, so
|
||||
/// this v2 reader is a compatible read of a genuine v1 row too: `open` still
|
||||
/// gates strictly on the row's own declared `schema_id` (a fresh legacy
|
||||
/// branch recognizes `LEGACY_STORE_SCHEMA_V1` by name, never silently), but
|
||||
/// once recognized, decodes it with exactly the same struct and upgrades it
|
||||
/// in place rather than discarding the turn it names as unreadable.
|
||||
const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v2";
|
||||
/// The schema id every row written before `question_surface_version` existed
|
||||
/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and
|
||||
/// upgraded to `STORE_SCHEMA` in place (PA.f172).
|
||||
const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1";
|
||||
const STORE_KEY: &str = "primary";
|
||||
|
||||
/// The one round budget: exhausting it closes the turn and narrates, rather
|
||||
|
|
@ -245,10 +258,16 @@ struct PlayTurn {
|
|||
/// world's current revision happens to be when a later request arrives.
|
||||
/// This is what lets an answer be refused as stale by "older than the
|
||||
/// version the question first appeared on" rather than by "not exactly
|
||||
/// today's version," the comparison PA.f161 replaces. No
|
||||
/// `#[serde(default)]`, matching `question`'s own row-schema neighbors
|
||||
/// above (PA.f95): the store has never been deployed, so no row anywhere
|
||||
/// is missing it.
|
||||
/// today's version," the comparison PA.f161 replaces. `#[serde(default)]`
|
||||
/// (PA.f172, reversing the earlier PA.f95 reasoning here): a row written
|
||||
/// before this field existed — a real shape on a workstation that ran
|
||||
/// Dungeon before Cut 11, not a hypothetical — decodes to `None`, exactly
|
||||
/// what "no question ever recorded a surface version" means for a turn
|
||||
/// this old. `STORE_SCHEMA` moved to v2 alongside this default so the
|
||||
/// canonical re-encode check below still holds current-schema rows to
|
||||
/// their exact bytes; only the legacy v1 read path in
|
||||
/// `PlayTurnStore::open` relies on this default to recover an older row.
|
||||
#[serde(default)]
|
||||
question_surface_version: Option<u64>,
|
||||
refusal: Option<String>,
|
||||
narration: Option<String>,
|
||||
|
|
@ -341,8 +360,9 @@ impl PlayTurn {
|
|||
/// `open_question_id` is, since `turn.question` and
|
||||
/// `turn.question_surface_version` are only ever set alongside it
|
||||
/// (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in the same
|
||||
/// round, and `answer_turn` clears `question`/`question_surface_version`
|
||||
/// together).
|
||||
/// round, and `answer_turn` and `close_with_fault` both clear
|
||||
/// `question`/`question_surface_version` on their own way out of
|
||||
/// `AwaitingPlayer`, PA.f173).
|
||||
fn open_question_of(turn: &PlayTurn) -> Option<OpenQuestion> {
|
||||
let id = turn.open_question_id()?;
|
||||
Some(OpenQuestion {
|
||||
|
|
@ -463,7 +483,38 @@ impl PlayTurnStore {
|
|||
}
|
||||
(row.clone(), state)
|
||||
}
|
||||
_ => bail!("play-turn v1 store contains foreign or legacy records; start with a fresh store"),
|
||||
// PA.f172: a row written before `question_surface_version`
|
||||
// existed — named legacy, not corrupt, because it is neither: it
|
||||
// is a real, previously-canonical shape this process itself
|
||||
// wrote, under the schema id that was current when it did.
|
||||
// `question_surface_version` now carries `#[serde(default)]`, so
|
||||
// the very same `PlayTurnStoreState` decodes it correctly (the
|
||||
// field reads back `None`, matching what "no question ever
|
||||
// recorded a surface version" means for a turn this old). This
|
||||
// branch upgrades the row to `STORE_SCHEMA` (v2) in place and
|
||||
// persists that upgrade immediately, so the legacy path is only
|
||||
// ever taken once per row; the canonical re-encode check does
|
||||
// not apply here — a v1 row's bytes were never canonical under
|
||||
// v2 and were never meant to be — it resumes for every write
|
||||
// this store makes from here on.
|
||||
[row]
|
||||
if row.r#type == STORE_TYPE
|
||||
&& row.key == STORE_KEY
|
||||
&& row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) =>
|
||||
{
|
||||
let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload)
|
||||
.context("play-turn legacy v1 state is corrupt")?;
|
||||
if state.schema != LEGACY_STORE_SCHEMA_V1 {
|
||||
bail!("play-turn legacy v1 state schema is invalid");
|
||||
}
|
||||
state.schema = STORE_SCHEMA.into();
|
||||
let upgraded_row = envelope(&state)?;
|
||||
if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()])? {
|
||||
bail!("play-turn store changed while upgrading a legacy v1 row");
|
||||
}
|
||||
(upgraded_row, state)
|
||||
}
|
||||
_ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"),
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
|
|
@ -1150,6 +1201,15 @@ impl PlayTable {
|
|||
async fn close_with_fault(&self, turn: &mut PlayTurn, detail: String) -> Result<(), PlayError> {
|
||||
turn.state = PlayTurnState::Closed;
|
||||
turn.fault = Some(detail);
|
||||
// PA.f173: a fault can close a turn that was `AwaitingPlayer`, and
|
||||
// `answer_turn` is not the only door out of that state — clear the
|
||||
// open question here too, the same way `answer_turn` does, so a
|
||||
// closed turn never carries a question record alongside it. This was
|
||||
// unobservable before this fix only because `open_question_id` gates
|
||||
// on `state == AwaitingPlayer`, which a fault always leaves behind by
|
||||
// setting `state = Closed` in the same write.
|
||||
turn.question = None;
|
||||
turn.question_surface_version = None;
|
||||
self.persist(turn).await
|
||||
}
|
||||
|
||||
|
|
@ -1425,12 +1485,25 @@ impl PlayTable {
|
|||
None,
|
||||
);
|
||||
turn.question = Some(question);
|
||||
// PA.f161: the world's own surface version at the instant
|
||||
// this question opens, so a later answer is judged against
|
||||
// the version the question actually first appeared on, never
|
||||
// whatever the world's version happens to be when the
|
||||
// answer arrives.
|
||||
turn.question_surface_version = Some(crate::eve::surface_version(Some(&snapshot)));
|
||||
// PA.f161/PA.f175: the world's own surface version at the
|
||||
// instant this question opens, so a later answer is judged
|
||||
// against the version the question actually first appeared
|
||||
// on, never whatever the world's version happens to be when
|
||||
// the answer arrives. This must be a fresh snapshot taken
|
||||
// right here, not the round-entry `snapshot` above: a round
|
||||
// that authors (a `PLAY_TOOLS` run, committed earlier in this
|
||||
// same `while` loop) and then asks would otherwise record the
|
||||
// version as of before its own commits, not the version the
|
||||
// question actually opened on.
|
||||
let question_snapshot = match self.fetch_round_snapshot().await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(detail) => {
|
||||
self.close_with_fault(turn, detail).await?;
|
||||
return Ok(RoundOutcome::Closed);
|
||||
}
|
||||
};
|
||||
turn.question_surface_version =
|
||||
Some(crate::eve::surface_version(Some(&question_snapshot)));
|
||||
turn.state = PlayTurnState::AwaitingPlayer;
|
||||
return Ok(RoundOutcome::AwaitingPlayer);
|
||||
}
|
||||
|
|
@ -3219,6 +3292,317 @@ pub(crate) mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// PA.f175: `execute_round` takes one snapshot at round entry
|
||||
/// (`round_snapshot`) and, before this fix, reused it for every arm in
|
||||
/// that same round — including `ASK_PLAYER_TOOL` — so a round that
|
||||
/// commits an authoring call (a `PLAY_TOOLS` run) and then asks in the
|
||||
/// same round recorded `question_surface_version` from before its own
|
||||
/// commit. A later "is this answer stale" comparison
|
||||
/// (`runtime.rs::an_answer_naming_a_version_older_than_the_question_is_refused_as_stale`)
|
||||
/// judges a version the question's own commit had already passed, so an
|
||||
/// answer naming the version the commit actually landed at — the version
|
||||
/// a real client would have just been served — could be wrongly refused
|
||||
/// as older than the question.
|
||||
///
|
||||
/// Mutation: revert `execute_round`'s `ASK_PLAYER_TOOL` arm to record
|
||||
/// `crate::eve::surface_version(Some(&snapshot))` (the round-entry
|
||||
/// snapshot) instead of fetching a fresh one at the moment the question
|
||||
/// opens — this test's asserted version would then equal the version
|
||||
/// from before the round's own `declare_fact` commit, one behind the
|
||||
/// version this test expects.
|
||||
#[tokio::test]
|
||||
async fn a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round() {
|
||||
let fixture = play_world(None, "player-author-then-ask").await;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let world_before = fixture.world.snapshot().await.unwrap();
|
||||
let version_before = crate::eve::surface_version(Some(&world_before));
|
||||
|
||||
let round = output(
|
||||
"r0",
|
||||
vec![
|
||||
call_event(
|
||||
"c0",
|
||||
"declare_fact",
|
||||
serde_json::json!({
|
||||
"handle": "ruling",
|
||||
"label": "The Ruling",
|
||||
"statement": "The gate creaks open on its own.",
|
||||
"standing": {"standing": "ruled"},
|
||||
}),
|
||||
),
|
||||
call_event(
|
||||
"c1",
|
||||
ASK_PLAYER_TOOL,
|
||||
serde_json::json!({"question": "Which way?"}),
|
||||
),
|
||||
],
|
||||
);
|
||||
let personas = PersonaLane::new(
|
||||
ControllerPort::new(fixture.world.clone()),
|
||||
ScriptedPort::new(vec![]),
|
||||
"gpt-5.6-sol".into(),
|
||||
"gpt-5.6-sol".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let table = PlayTable::new(
|
||||
fixture.world.clone(),
|
||||
personas,
|
||||
ScriptedPort::new(vec![round]),
|
||||
"gpt-5.6-terra".into(),
|
||||
Arc::new(Semaphore::new(2)),
|
||||
&store_path,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
table
|
||||
.run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let asked_view = table.current_turn_view().await.unwrap();
|
||||
let question = asked_view
|
||||
.question
|
||||
.as_ref()
|
||||
.expect("the scripted ask_player call must open a question");
|
||||
|
||||
let world_after = fixture.world.snapshot().await.unwrap();
|
||||
let version_after = crate::eve::surface_version(Some(&world_after));
|
||||
assert!(
|
||||
version_after > version_before,
|
||||
"declare_fact must have committed and moved the world's own revision \
|
||||
({version_before} -> {version_after})"
|
||||
);
|
||||
assert_eq!(
|
||||
question.surface_version, version_after,
|
||||
"the question's own recorded version must be the version as of when it opened \
|
||||
(after this round's own declare_fact commit, {version_after}), not the \
|
||||
round-entry version {version_before}"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f173: `close_with_fault` used to leave `question` and
|
||||
/// `question_surface_version` set on the turn it closed, though a
|
||||
/// `Closed` turn should never still carry an open question. This was
|
||||
/// unobservable through `current_turn_view`/`open_question_id` only
|
||||
/// because both gate on `state == AwaitingPlayer`, which closing always
|
||||
/// leaves behind in the same write — so this test reaches past that
|
||||
/// gate, at the raw persisted row.
|
||||
///
|
||||
/// Mutation: remove the `turn.question = None; turn.question_surface_version
|
||||
/// = None;` lines from `close_with_fault` — the persisted row would then
|
||||
/// still carry the question's own text and version after the turn
|
||||
/// closed, and this test's assertions would fail.
|
||||
#[tokio::test]
|
||||
async fn close_with_fault_clears_the_open_question_off_the_persisted_row() {
|
||||
let fixture = play_world(None, "player-fault-clears-question").await;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let ask = output(
|
||||
"r0",
|
||||
vec![call_event(
|
||||
"c0",
|
||||
ASK_PLAYER_TOOL,
|
||||
serde_json::json!({"question": "Which way?"}),
|
||||
)],
|
||||
);
|
||||
let personas = PersonaLane::new(
|
||||
ControllerPort::new(fixture.world.clone()),
|
||||
ScriptedPort::new(vec![]),
|
||||
"gpt-5.6-sol".into(),
|
||||
"gpt-5.6-sol".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let table = PlayTable::new(
|
||||
fixture.world.clone(),
|
||||
personas,
|
||||
ScriptedPort::new(vec![ask]),
|
||||
"gpt-5.6-terra".into(),
|
||||
Arc::new(Semaphore::new(2)),
|
||||
&store_path,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
table
|
||||
.run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let asked_view = table.current_turn_view().await.unwrap();
|
||||
assert_eq!(asked_view.state, PlayTurnState::AwaitingPlayer);
|
||||
|
||||
let mut turn = table.store.lock().await.current().unwrap().clone();
|
||||
assert!(turn.question.is_some());
|
||||
assert!(turn.question_surface_version.is_some());
|
||||
table
|
||||
.close_with_fault(&mut turn, "manufactured fault for PA.f173".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let persisted = table.store.lock().await.current().unwrap().clone();
|
||||
assert_eq!(persisted.state, PlayTurnState::Closed);
|
||||
assert_eq!(
|
||||
persisted.question, None,
|
||||
"a closed turn must not still carry the question it closed on top of"
|
||||
);
|
||||
assert_eq!(
|
||||
persisted.question_surface_version, None,
|
||||
"a closed turn must not still carry the question's own surface version"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field
|
||||
/// minus `question_surface_version` — the shape every row actually
|
||||
/// written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). Test-only:
|
||||
/// no production writer ever mints this shape again.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LegacyPlayTurnV1 {
|
||||
turn_id: String,
|
||||
applied_keys: Vec<String>,
|
||||
opening_prompt: String,
|
||||
player_prose: Vec<String>,
|
||||
rounds: Vec<InferenceOutput>,
|
||||
calls: Vec<CallRecord>,
|
||||
persona_turns: Vec<(SubjectId, PersonaTurn)>,
|
||||
question: Option<String>,
|
||||
refusal: Option<String>,
|
||||
narration: Option<String>,
|
||||
fault: Option<String>,
|
||||
state: PlayTurnState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LegacyPlayTurnStoreStateV1 {
|
||||
schema: String,
|
||||
turn: Option<LegacyPlayTurnV1>,
|
||||
ledger: KeyLedger,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// PA.f172: a row written before `question_surface_version` existed —
|
||||
/// under `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process
|
||||
/// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is
|
||||
/// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its
|
||||
/// turn recovered rather than discarded: the missing field decodes to
|
||||
/// `None`, exactly what "no question ever recorded a surface version"
|
||||
/// means for a turn this old. The row is also upgraded to `STORE_SCHEMA`
|
||||
/// in place, durably, so the legacy path is only ever taken once.
|
||||
///
|
||||
/// Mutation: remove the `schema_id.as_deref() ==
|
||||
/// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this
|
||||
/// row would then fall through to the catch-all and `open` would return
|
||||
/// `Err`, exactly PA.f172's own bug: a workstation's pre-Cut-11 row
|
||||
/// silently disables play with "the play table is unavailable".
|
||||
#[tokio::test]
|
||||
async fn a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let legacy_turn = LegacyPlayTurnV1 {
|
||||
turn_id: "11111111-1111-1111-1111-111111111111".into(),
|
||||
applied_keys: vec!["key-a".into()],
|
||||
opening_prompt: "You stand at the gate.".into(),
|
||||
player_prose: vec!["I knock.".into()],
|
||||
rounds: Vec::new(),
|
||||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
state: PlayTurnState::Running,
|
||||
};
|
||||
let legacy_state = LegacyPlayTurnStoreStateV1 {
|
||||
schema: LEGACY_STORE_SCHEMA_V1.into(),
|
||||
turn: Some(legacy_turn),
|
||||
ledger: KeyLedger::default(),
|
||||
revision: 3,
|
||||
};
|
||||
let legacy_row = CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec_named(&legacy_state).unwrap(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()),
|
||||
};
|
||||
{
|
||||
let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
assert!(
|
||||
raw_store
|
||||
.compare_and_swap_batch(&[], vec![legacy_row])
|
||||
.unwrap(),
|
||||
"seeding the legacy row must succeed against an empty store"
|
||||
);
|
||||
}
|
||||
|
||||
let store = PlayTurnStore::open(&store_path)
|
||||
.expect("a genuine pre-Cut-11 row must open, not be refused as corrupt");
|
||||
let recovered = store.current().expect("the legacy row's own turn must be recovered");
|
||||
assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111");
|
||||
assert_eq!(recovered.opening_prompt, "You stand at the gate.");
|
||||
assert_eq!(
|
||||
recovered.question_surface_version, None,
|
||||
"a pre-Cut-11 turn never recorded a surface version; the missing field must decode to None"
|
||||
);
|
||||
assert_eq!(
|
||||
store.row.schema_id.as_deref(),
|
||||
Some(STORE_SCHEMA),
|
||||
"the legacy row must be upgraded to the current schema in place, not left at v1"
|
||||
);
|
||||
|
||||
// Reopening must now take the strict current-schema path, proving
|
||||
// the upgrade was actually durably persisted, not only held in
|
||||
// memory on the first `open`. `store` must be dropped first: CultCache's
|
||||
// own single-owner lock refuses a second `OwnedRedbMessagePackBackingStore::new`
|
||||
// on the same path while the first is still held (the same custody
|
||||
// rule `pull_current_from_disk_for_test`'s own doc comment names).
|
||||
let recovered_turn_id = recovered.turn_id.clone();
|
||||
drop(store);
|
||||
let reopened = PlayTurnStore::open(&store_path).unwrap();
|
||||
assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id);
|
||||
}
|
||||
|
||||
/// PA.f172's own negative: a row that matches the current schema id but
|
||||
/// whose bytes do not decode at all is still refused as corrupt — the
|
||||
/// legacy path recognizes only the one schema id every pre-Cut-11
|
||||
/// process actually wrote, never a general-purpose "try to read it
|
||||
/// anyway" escape hatch for a row that is genuinely damaged.
|
||||
#[tokio::test]
|
||||
async fn a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let corrupt_row = CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: vec![0xC1], // msgpack's own reserved "never used" marker byte
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(STORE_SCHEMA.into()),
|
||||
};
|
||||
{
|
||||
let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
assert!(
|
||||
raw_store
|
||||
.compare_and_swap_batch(&[], vec![corrupt_row])
|
||||
.unwrap(),
|
||||
"seeding the corrupt row must succeed against an empty store"
|
||||
);
|
||||
}
|
||||
|
||||
// `.expect_err` needs `T: Debug`; `PlayTurnStore` does not derive it
|
||||
// (its own row holds turn text nobody wants dumped by a failed
|
||||
// assertion), so this matches directly instead.
|
||||
let error = match PlayTurnStore::open(&store_path) {
|
||||
Ok(_) => panic!("undecodable bytes must still refuse to open"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
error.to_string().contains("corrupt"),
|
||||
"a genuinely undecodable row must still be named corrupt, not legacy: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fork Self ruled on, isolated: a commit that changes only
|
||||
/// `turn.refusal` — exactly the shape `execute_actor_call`'s
|
||||
/// `ActionRejected` arm leaves a row in (the call's own record is
|
||||
|
|
|
|||
|
|
@ -1571,8 +1571,21 @@ async fn runtime_readiness(state: &AppState) -> anyhow::Result<Value> {
|
|||
}
|
||||
None => "unavailable",
|
||||
};
|
||||
// PA.f172: the play table can fail to open independently of
|
||||
// `controllers` (a `PlayTurnStore` a prior session's row makes unreadable),
|
||||
// and before this the only trace was `tracing::warn!("the play table is
|
||||
// unavailable; world authority remains online")` at startup — visible in
|
||||
// a log an operator has to go looking for, never on the readiness
|
||||
// surface a client or operator actually watches. Same `Option` ->
|
||||
// "ok"/"unavailable" shape as `projectionStatus`/`controllerStatus`
|
||||
// above; this does not invent a new channel.
|
||||
let play_status = match &state.play {
|
||||
Some(_) => "ok",
|
||||
None => "unavailable",
|
||||
};
|
||||
health["projectionStatus"] = Value::String(projection_status.into());
|
||||
health["controllerStatus"] = Value::String(controller_status.into());
|
||||
health["playStatus"] = Value::String(play_status.into());
|
||||
Ok(health)
|
||||
}
|
||||
|
||||
|
|
@ -2179,6 +2192,27 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// PA.f172: `state.play` being `None` — a play table that failed to
|
||||
/// open, for any reason, `PlayTurnStore::open` returning `Err` among
|
||||
/// them — must be visible on `/health`, the same operator-facing surface
|
||||
/// `projectionStatus`/`controllerStatus` already report through, not
|
||||
/// only in a `tracing::warn!` at startup.
|
||||
///
|
||||
/// Mutation: delete the `health["playStatus"] = ...` line from
|
||||
/// `runtime_readiness` — `/health`'s own JSON would then carry no
|
||||
/// `playStatus` key at all, and both assertions below would fail.
|
||||
#[tokio::test]
|
||||
async fn health_reports_play_status_from_state_play() {
|
||||
let fixture = fixture().await;
|
||||
let available = get(&fixture.state, &fixture.cookie, "/health").await;
|
||||
assert_eq!(available["playStatus"], "ok", "{available}");
|
||||
|
||||
let mut degraded_state = fixture.state.clone();
|
||||
degraded_state.play = None;
|
||||
let unavailable = get(°raded_state, &fixture.cookie, "/health").await;
|
||||
assert_eq!(unavailable["playStatus"], "unavailable", "{unavailable}");
|
||||
}
|
||||
|
||||
fn route_snapshot_request(
|
||||
message_id: &str,
|
||||
schema_ids: Option<Vec<String>>,
|
||||
|
|
@ -2518,6 +2552,16 @@ mod tests {
|
|||
/// `play::tests::a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration`.
|
||||
#[tokio::test]
|
||||
async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() {
|
||||
if !eve_client_bridge_is_available() {
|
||||
eprintln!(
|
||||
"SKIPPED world_create_seed_activate_and_play_round_trip_through_the_real_client: \
|
||||
`node` or the vendored lowering's built `dist/index.js` is not available in this \
|
||||
environment, so this test did not drive the real Eve client bridge. Run this where \
|
||||
`node` is on PATH and `vendor/eve/packages/eve-browser-lowering` has been built \
|
||||
(`npm install && npm run build` in that package) to exercise this check."
|
||||
);
|
||||
return;
|
||||
}
|
||||
let fixture = fixture().await;
|
||||
let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await;
|
||||
|
||||
|
|
@ -2853,6 +2897,16 @@ mod tests {
|
|||
/// accepted.
|
||||
#[tokio::test]
|
||||
async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() {
|
||||
if !eve_client_bridge_is_available() {
|
||||
eprintln!(
|
||||
"SKIPPED a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted: \
|
||||
`node` or the vendored lowering's built `dist/index.js` is not available in this \
|
||||
environment, so this test did not drive the real Eve client bridge. Run this where \
|
||||
`node` is on PATH and `vendor/eve/packages/eve-browser-lowering` has been built \
|
||||
(`npm install && npm run build` in that package) to exercise this check."
|
||||
);
|
||||
return;
|
||||
}
|
||||
let fixture = fixture().await;
|
||||
let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await;
|
||||
let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await;
|
||||
|
|
|
|||
Loading…
Reference in a new issue